@inoo-ch/payload-image-optimizer 1.2.0 → 1.3.0
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inoo-ch/payload-image-optimizer",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Payload CMS plugin for automatic image optimization — WebP/AVIF conversion, resize, EXIF strip, ThumbHash placeholders, and bulk regeneration",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import React from 'react'
|
|
4
4
|
import { thumbHashToDataURL } from 'thumbhash'
|
|
5
|
-
import { useAllFormFields
|
|
5
|
+
import { useAllFormFields } from '@payloadcms/ui'
|
|
6
6
|
|
|
7
7
|
const formatBytes = (bytes: number): string => {
|
|
8
8
|
if (bytes === 0) return '0 B'
|
|
@@ -19,90 +19,15 @@ const statusColors: Record<string, string> = {
|
|
|
19
19
|
error: '#ef4444',
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
const POLL_INTERVAL_MS = 2000
|
|
23
|
-
|
|
24
|
-
type PolledData = {
|
|
25
|
-
status?: string
|
|
26
|
-
originalSize?: number
|
|
27
|
-
optimizedSize?: number
|
|
28
|
-
thumbHash?: string
|
|
29
|
-
error?: string
|
|
30
|
-
variants?: Array<{
|
|
31
|
-
format?: string
|
|
32
|
-
filename?: string
|
|
33
|
-
filesize?: number
|
|
34
|
-
width?: number
|
|
35
|
-
height?: number
|
|
36
|
-
}>
|
|
37
|
-
}
|
|
38
|
-
|
|
39
22
|
export const OptimizationStatus: React.FC<{ path?: string }> = (props) => {
|
|
40
23
|
const [formState] = useAllFormFields()
|
|
41
|
-
const { collectionSlug, id } = useDocumentInfo()
|
|
42
24
|
const basePath = props.path ?? 'imageOptimizer'
|
|
43
25
|
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
const [polledData, setPolledData] = React.useState<PolledData | null>(null)
|
|
51
|
-
|
|
52
|
-
// Reset polled data when a new upload changes the form status back to pending
|
|
53
|
-
React.useEffect(() => {
|
|
54
|
-
if (formStatus === 'pending') {
|
|
55
|
-
setPolledData(null)
|
|
56
|
-
}
|
|
57
|
-
}, [formStatus])
|
|
58
|
-
|
|
59
|
-
// Poll for status updates when status is non-terminal
|
|
60
|
-
React.useEffect(() => {
|
|
61
|
-
const currentStatus = polledData?.status ?? formStatus
|
|
62
|
-
if (!currentStatus || currentStatus === 'complete' || currentStatus === 'error') return
|
|
63
|
-
if (!collectionSlug || !id) return
|
|
64
|
-
|
|
65
|
-
const controller = new AbortController()
|
|
66
|
-
|
|
67
|
-
const poll = async () => {
|
|
68
|
-
try {
|
|
69
|
-
const res = await fetch(`/api/${collectionSlug}/${id}?depth=0`, {
|
|
70
|
-
signal: controller.signal,
|
|
71
|
-
})
|
|
72
|
-
if (!res.ok) return
|
|
73
|
-
const doc = await res.json()
|
|
74
|
-
const optimizer = doc.imageOptimizer
|
|
75
|
-
if (!optimizer) return
|
|
76
|
-
|
|
77
|
-
setPolledData({
|
|
78
|
-
status: optimizer.status,
|
|
79
|
-
originalSize: optimizer.originalSize,
|
|
80
|
-
optimizedSize: optimizer.optimizedSize,
|
|
81
|
-
thumbHash: optimizer.thumbHash,
|
|
82
|
-
error: optimizer.error,
|
|
83
|
-
variants: optimizer.variants,
|
|
84
|
-
})
|
|
85
|
-
} catch {
|
|
86
|
-
// Silently ignore fetch errors (abort, network issues)
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const intervalId = setInterval(poll, POLL_INTERVAL_MS)
|
|
91
|
-
// Run immediately on mount
|
|
92
|
-
poll()
|
|
93
|
-
|
|
94
|
-
return () => {
|
|
95
|
-
controller.abort()
|
|
96
|
-
clearInterval(intervalId)
|
|
97
|
-
}
|
|
98
|
-
}, [polledData?.status, formStatus, collectionSlug, id])
|
|
99
|
-
|
|
100
|
-
// Use polled data when available, otherwise fall back to form state
|
|
101
|
-
const status = polledData?.status ?? formStatus
|
|
102
|
-
const originalSize = polledData?.originalSize ?? formOriginalSize
|
|
103
|
-
const optimizedSize = polledData?.optimizedSize ?? formOptimizedSize
|
|
104
|
-
const thumbHash = polledData?.thumbHash ?? formThumbHash
|
|
105
|
-
const error = polledData?.error ?? formError
|
|
26
|
+
const status = formState[`${basePath}.status`]?.value as string | undefined
|
|
27
|
+
const originalSize = formState[`${basePath}.originalSize`]?.value as number | undefined
|
|
28
|
+
const optimizedSize = formState[`${basePath}.optimizedSize`]?.value as number | undefined
|
|
29
|
+
const thumbHash = formState[`${basePath}.thumbHash`]?.value as string | undefined
|
|
30
|
+
const error = formState[`${basePath}.error`]?.value as string | undefined
|
|
106
31
|
|
|
107
32
|
const thumbHashUrl = React.useMemo(() => {
|
|
108
33
|
if (!thumbHash) return null
|
|
@@ -114,30 +39,26 @@ export const OptimizationStatus: React.FC<{ path?: string }> = (props) => {
|
|
|
114
39
|
}
|
|
115
40
|
}, [thumbHash])
|
|
116
41
|
|
|
117
|
-
// Read variants from
|
|
42
|
+
// Read variants array from form state
|
|
43
|
+
const variantsField = formState[`${basePath}.variants`]
|
|
44
|
+
const rowCount = (variantsField as any)?.rows?.length ?? 0
|
|
118
45
|
const variants: Array<{
|
|
119
46
|
format?: string
|
|
120
47
|
filename?: string
|
|
121
48
|
filesize?: number
|
|
122
49
|
width?: number
|
|
123
50
|
height?: number
|
|
124
|
-
}> =
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
width: formState[`${basePath}.variants.${i}.width`]?.value as number | undefined,
|
|
136
|
-
height: formState[`${basePath}.variants.${i}.height`]?.value as number | undefined,
|
|
137
|
-
})
|
|
138
|
-
}
|
|
139
|
-
return formVariants
|
|
140
|
-
}, [polledData?.variants, formState, basePath])
|
|
51
|
+
}> = []
|
|
52
|
+
|
|
53
|
+
for (let i = 0; i < rowCount; i++) {
|
|
54
|
+
variants.push({
|
|
55
|
+
format: formState[`${basePath}.variants.${i}.format`]?.value as string | undefined,
|
|
56
|
+
filename: formState[`${basePath}.variants.${i}.filename`]?.value as string | undefined,
|
|
57
|
+
filesize: formState[`${basePath}.variants.${i}.filesize`]?.value as number | undefined,
|
|
58
|
+
width: formState[`${basePath}.variants.${i}.width`]?.value as number | undefined,
|
|
59
|
+
height: formState[`${basePath}.variants.${i}.height`]?.value as number | undefined,
|
|
60
|
+
})
|
|
61
|
+
}
|
|
141
62
|
|
|
142
63
|
if (!status) {
|
|
143
64
|
return (
|
package/src/hooks/afterChange.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { CollectionAfterChangeHook } from 'payload'
|
|
|
5
5
|
import type { ResolvedImageOptimizerConfig } from '../types.js'
|
|
6
6
|
import { resolveCollectionConfig } from '../defaults.js'
|
|
7
7
|
import { resolveStaticDir } from '../utilities/resolveStaticDir.js'
|
|
8
|
+
import { isCloudStorage } from '../utilities/storage.js'
|
|
8
9
|
|
|
9
10
|
export const createAfterChangeHook = (
|
|
10
11
|
resolvedConfig: ResolvedImageOptimizerConfig,
|
|
@@ -16,28 +17,33 @@ export const createAfterChangeHook = (
|
|
|
16
17
|
if (!req.file || !req.file.data || !req.file.mimetype?.startsWith('image/')) return doc
|
|
17
18
|
|
|
18
19
|
const collectionConfig = req.payload.collections[collectionSlug as keyof typeof req.payload.collections].config
|
|
19
|
-
const
|
|
20
|
+
const cloudStorage = isCloudStorage(collectionConfig)
|
|
20
21
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
22
|
+
// When using local storage, overwrite the file on disk with the processed buffer.
|
|
23
|
+
// Payload's uploadFiles step writes the original buffer; we replace it here.
|
|
24
|
+
// When using cloud storage, skip — the cloud adapter's afterChange hook already
|
|
25
|
+
// uploads the correct buffer from req.file.data (set in our beforeChange hook).
|
|
26
|
+
if (!cloudStorage) {
|
|
27
|
+
const staticDir = resolveStaticDir(collectionConfig)
|
|
28
|
+
const processedBuffer = context.imageOptimizer_processedBuffer as Buffer | undefined
|
|
29
|
+
if (processedBuffer && doc.filename && staticDir) {
|
|
30
|
+
const safeFilename = path.basename(doc.filename as string)
|
|
31
|
+
const filePath = path.join(staticDir, safeFilename)
|
|
32
|
+
await fs.writeFile(filePath, processedBuffer)
|
|
30
33
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
// If replaceOriginal changed the filename, clean up the old file Payload wrote
|
|
35
|
+
const originalFilename = context.imageOptimizer_originalFilename as string | undefined
|
|
36
|
+
if (originalFilename && originalFilename !== safeFilename) {
|
|
37
|
+
const oldFilePath = path.join(staticDir, path.basename(originalFilename))
|
|
38
|
+
await fs.unlink(oldFilePath).catch(() => {
|
|
39
|
+
// Old file may not exist if Payload used the new filename
|
|
40
|
+
})
|
|
41
|
+
}
|
|
38
42
|
}
|
|
39
43
|
}
|
|
40
44
|
|
|
45
|
+
const perCollectionConfig = resolveCollectionConfig(resolvedConfig, collectionSlug)
|
|
46
|
+
|
|
41
47
|
// When replaceOriginal is on and only one format is configured, the main file
|
|
42
48
|
// is already converted — skip the async job and mark complete immediately.
|
|
43
49
|
if (perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length <= 1) {
|
|
@@ -55,7 +61,25 @@ export const createAfterChangeHook = (
|
|
|
55
61
|
return doc
|
|
56
62
|
}
|
|
57
63
|
|
|
58
|
-
//
|
|
64
|
+
// With cloud storage, variant files cannot be written — skip the async job
|
|
65
|
+
// and mark complete. CDN-level image optimization (e.g. Next.js Image) can
|
|
66
|
+
// serve alternative formats on the fly.
|
|
67
|
+
if (cloudStorage) {
|
|
68
|
+
await req.payload.update({
|
|
69
|
+
collection: collectionSlug,
|
|
70
|
+
id: doc.id,
|
|
71
|
+
data: {
|
|
72
|
+
imageOptimizer: {
|
|
73
|
+
status: 'complete',
|
|
74
|
+
variants: [],
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
context: { imageOptimizer_skip: true },
|
|
78
|
+
})
|
|
79
|
+
return doc
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Queue async format conversion job for remaining variants (local storage only)
|
|
59
83
|
await req.payload.jobs.queue({
|
|
60
84
|
task: 'imageOptimizer_convertFormats',
|
|
61
85
|
input: {
|
|
@@ -55,8 +55,17 @@ export const createBeforeChangeHook = (
|
|
|
55
55
|
data.imageOptimizer.thumbHash = await generateThumbHash(finalBuffer)
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
//
|
|
59
|
-
// (
|
|
58
|
+
// Write processed buffer back to req.file so cloud storage adapters
|
|
59
|
+
// (which read req.file in their afterChange hook) upload the optimized version.
|
|
60
|
+
// Payload's own uploadFiles step does NOT re-read req.file.data for its local
|
|
61
|
+
// disk write, so we also store the buffer in context for our afterChange hook
|
|
62
|
+
// to overwrite the local file when local storage is enabled.
|
|
63
|
+
req.file.data = finalBuffer
|
|
64
|
+
req.file.size = finalSize
|
|
65
|
+
if (perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0) {
|
|
66
|
+
req.file.name = data.filename
|
|
67
|
+
req.file.mimetype = data.mimeType
|
|
68
|
+
}
|
|
60
69
|
context.imageOptimizer_processedBuffer = finalBuffer
|
|
61
70
|
|
|
62
71
|
return data
|
|
@@ -7,6 +7,7 @@ import type { ResolvedImageOptimizerConfig } from '../types.js'
|
|
|
7
7
|
import { resolveCollectionConfig } from '../defaults.js'
|
|
8
8
|
import { convertFormat } from '../processing/index.js'
|
|
9
9
|
import { resolveStaticDir } from '../utilities/resolveStaticDir.js'
|
|
10
|
+
import { fetchFileBuffer, isCloudStorage } from '../utilities/storage.js'
|
|
10
11
|
|
|
11
12
|
export const createConvertFormatsHandler = (resolvedConfig: ResolvedImageOptimizerConfig) => {
|
|
12
13
|
return async ({ input, req }: { input: { collectionSlug: string; docId: string }; req: any }) => {
|
|
@@ -17,16 +18,31 @@ export const createConvertFormatsHandler = (resolvedConfig: ResolvedImageOptimiz
|
|
|
17
18
|
})
|
|
18
19
|
|
|
19
20
|
const collectionConfig = req.payload.collections[input.collectionSlug as keyof typeof req.payload.collections].config
|
|
20
|
-
const
|
|
21
|
+
const cloudStorage = isCloudStorage(collectionConfig)
|
|
22
|
+
|
|
23
|
+
// Cloud storage: variant files cannot be uploaded without direct adapter access.
|
|
24
|
+
// Mark as complete — CDN-level image optimization handles format conversion.
|
|
25
|
+
if (cloudStorage) {
|
|
26
|
+
await req.payload.update({
|
|
27
|
+
collection: input.collectionSlug as CollectionSlug,
|
|
28
|
+
id: input.docId,
|
|
29
|
+
data: {
|
|
30
|
+
imageOptimizer: {
|
|
31
|
+
status: 'complete',
|
|
32
|
+
variants: [],
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
context: { imageOptimizer_skip: true },
|
|
36
|
+
})
|
|
37
|
+
return { output: { variantsGenerated: 0 } }
|
|
38
|
+
}
|
|
21
39
|
|
|
40
|
+
const staticDir = resolveStaticDir(collectionConfig)
|
|
22
41
|
if (!staticDir) {
|
|
23
42
|
throw new Error(`No staticDir configured for collection "${input.collectionSlug}"`)
|
|
24
43
|
}
|
|
25
44
|
|
|
26
|
-
|
|
27
|
-
const safeFilename = path.basename(doc.filename)
|
|
28
|
-
const filePath = path.join(staticDir, safeFilename)
|
|
29
|
-
const fileBuffer = await fs.readFile(filePath)
|
|
45
|
+
const fileBuffer = await fetchFileBuffer(doc, collectionConfig)
|
|
30
46
|
|
|
31
47
|
const variants: Array<{
|
|
32
48
|
filename: string
|
|
@@ -46,6 +62,8 @@ export const createConvertFormatsHandler = (resolvedConfig: ResolvedImageOptimiz
|
|
|
46
62
|
? perCollectionConfig.formats.slice(1)
|
|
47
63
|
: perCollectionConfig.formats
|
|
48
64
|
|
|
65
|
+
const safeFilename = path.basename(doc.filename)
|
|
66
|
+
|
|
49
67
|
for (const format of formatsToGenerate) {
|
|
50
68
|
const result = await convertFormat(fileBuffer, format.format, format.quality)
|
|
51
69
|
const variantFilename = `${path.parse(safeFilename).name}-optimized.${format.format}`
|
|
@@ -7,6 +7,7 @@ import type { ResolvedImageOptimizerConfig } from '../types.js'
|
|
|
7
7
|
import { resolveCollectionConfig } from '../defaults.js'
|
|
8
8
|
import { stripAndResize, generateThumbHash, convertFormat } from '../processing/index.js'
|
|
9
9
|
import { resolveStaticDir } from '../utilities/resolveStaticDir.js'
|
|
10
|
+
import { fetchFileBuffer, isCloudStorage } from '../utilities/storage.js'
|
|
10
11
|
|
|
11
12
|
export const createRegenerateDocumentHandler = (resolvedConfig: ResolvedImageOptimizerConfig) => {
|
|
12
13
|
return async ({ input, req }: { input: { collectionSlug: string; docId: string }; req: any }) => {
|
|
@@ -22,34 +23,14 @@ export const createRegenerateDocumentHandler = (resolvedConfig: ResolvedImageOpt
|
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
const collectionConfig = req.payload.collections[input.collectionSlug as keyof typeof req.payload.collections].config
|
|
25
|
-
const
|
|
26
|
+
const cloudStorage = isCloudStorage(collectionConfig)
|
|
26
27
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
const fileBuffer = await fetchFileBuffer(doc, collectionConfig)
|
|
29
|
+
const originalSize = fileBuffer.length
|
|
30
|
+
const perCollectionConfig = resolveCollectionConfig(resolvedConfig, input.collectionSlug)
|
|
30
31
|
|
|
31
32
|
// Sanitize filename to prevent path traversal
|
|
32
33
|
const safeFilename = path.basename(doc.filename)
|
|
33
|
-
const filePath = path.join(staticDir, safeFilename)
|
|
34
|
-
|
|
35
|
-
let fileBuffer: Buffer
|
|
36
|
-
try {
|
|
37
|
-
fileBuffer = await fs.readFile(filePath)
|
|
38
|
-
} catch {
|
|
39
|
-
// If file not on disk, try fetching from URL
|
|
40
|
-
if (doc.url) {
|
|
41
|
-
const url = doc.url.startsWith('http')
|
|
42
|
-
? doc.url
|
|
43
|
-
: `${process.env.NEXT_PUBLIC_SERVER_URL || ''}${doc.url}`
|
|
44
|
-
const response = await fetch(url)
|
|
45
|
-
fileBuffer = Buffer.from(await response.arrayBuffer())
|
|
46
|
-
} else {
|
|
47
|
-
throw new Error(`File not found: ${filePath}`)
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const originalSize = fileBuffer.length
|
|
52
|
-
const perCollectionConfig = resolveCollectionConfig(resolvedConfig, input.collectionSlug)
|
|
53
34
|
|
|
54
35
|
// Step 1: Strip metadata + resize
|
|
55
36
|
const processed = await stripAndResize(
|
|
@@ -73,26 +54,13 @@ export const createRegenerateDocumentHandler = (resolvedConfig: ResolvedImageOpt
|
|
|
73
54
|
newMimeType = converted.mimeType
|
|
74
55
|
}
|
|
75
56
|
|
|
76
|
-
// Write optimized file to disk
|
|
77
|
-
const newFilePath = path.join(staticDir, newFilename)
|
|
78
|
-
await fs.writeFile(newFilePath, mainBuffer)
|
|
79
|
-
|
|
80
|
-
// Clean up old file if filename changed
|
|
81
|
-
if (newFilename !== safeFilename) {
|
|
82
|
-
await fs.unlink(filePath).catch(() => {})
|
|
83
|
-
}
|
|
84
|
-
|
|
85
57
|
// Step 2: Generate ThumbHash
|
|
86
58
|
let thumbHash: string | undefined
|
|
87
59
|
if (resolvedConfig.generateThumbHash) {
|
|
88
60
|
thumbHash = await generateThumbHash(mainBuffer)
|
|
89
61
|
}
|
|
90
62
|
|
|
91
|
-
// Step 3:
|
|
92
|
-
const formatsToGenerate = perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0
|
|
93
|
-
? perCollectionConfig.formats.slice(1)
|
|
94
|
-
: perCollectionConfig.formats
|
|
95
|
-
|
|
63
|
+
// Step 3: Store the optimized file
|
|
96
64
|
const variants: Array<{
|
|
97
65
|
filename: string
|
|
98
66
|
filesize: number
|
|
@@ -103,47 +71,96 @@ export const createRegenerateDocumentHandler = (resolvedConfig: ResolvedImageOpt
|
|
|
103
71
|
width: number
|
|
104
72
|
}> = []
|
|
105
73
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
74
|
+
if (cloudStorage) {
|
|
75
|
+
// Cloud storage: re-upload the optimized file via Payload's update API.
|
|
76
|
+
// This triggers the cloud adapter's afterChange hook which uploads to cloud.
|
|
77
|
+
const updateData: Record<string, any> = {
|
|
78
|
+
imageOptimizer: {
|
|
79
|
+
originalSize,
|
|
80
|
+
optimizedSize: mainSize,
|
|
81
|
+
status: 'complete',
|
|
82
|
+
thumbHash,
|
|
83
|
+
variants: [],
|
|
84
|
+
error: null,
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (newFilename !== safeFilename) {
|
|
89
|
+
updateData.filename = newFilename
|
|
90
|
+
updateData.filesize = mainSize
|
|
91
|
+
updateData.mimeType = newMimeType
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
await req.payload.update({
|
|
95
|
+
collection: input.collectionSlug as CollectionSlug,
|
|
96
|
+
id: input.docId,
|
|
97
|
+
data: updateData,
|
|
98
|
+
file: {
|
|
99
|
+
data: mainBuffer,
|
|
100
|
+
mimetype: newMimeType || doc.mimeType,
|
|
101
|
+
name: newFilename,
|
|
102
|
+
size: mainSize,
|
|
103
|
+
},
|
|
104
|
+
context: { imageOptimizer_skip: true },
|
|
119
105
|
})
|
|
120
|
-
}
|
|
106
|
+
} else {
|
|
107
|
+
// Local storage: write files to disk
|
|
108
|
+
const staticDir = resolveStaticDir(collectionConfig)
|
|
109
|
+
const newFilePath = path.join(staticDir, newFilename)
|
|
110
|
+
await fs.writeFile(newFilePath, mainBuffer)
|
|
111
|
+
|
|
112
|
+
// Clean up old file if filename changed
|
|
113
|
+
if (newFilename !== safeFilename) {
|
|
114
|
+
const oldFilePath = path.join(staticDir, safeFilename)
|
|
115
|
+
await fs.unlink(oldFilePath).catch(() => {})
|
|
116
|
+
}
|
|
121
117
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
118
|
+
// Generate variant files (local storage only)
|
|
119
|
+
const formatsToGenerate = perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0
|
|
120
|
+
? perCollectionConfig.formats.slice(1)
|
|
121
|
+
: perCollectionConfig.formats
|
|
122
|
+
|
|
123
|
+
for (const format of formatsToGenerate) {
|
|
124
|
+
const result = await convertFormat(mainBuffer, format.format, format.quality)
|
|
125
|
+
const variantFilename = `${path.parse(newFilename).name}-optimized.${format.format}`
|
|
126
|
+
await fs.writeFile(path.join(staticDir, variantFilename), result.buffer)
|
|
127
|
+
|
|
128
|
+
variants.push({
|
|
129
|
+
format: format.format,
|
|
130
|
+
filename: variantFilename,
|
|
131
|
+
filesize: result.size,
|
|
132
|
+
width: result.width,
|
|
133
|
+
height: result.height,
|
|
134
|
+
mimeType: result.mimeType,
|
|
135
|
+
url: `/api/${input.collectionSlug}/file/${variantFilename}`,
|
|
136
|
+
})
|
|
137
|
+
}
|
|
133
138
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
139
|
+
// Update the document with optimization data
|
|
140
|
+
const updateData: Record<string, any> = {
|
|
141
|
+
imageOptimizer: {
|
|
142
|
+
originalSize,
|
|
143
|
+
optimizedSize: mainSize,
|
|
144
|
+
status: 'complete',
|
|
145
|
+
thumbHash,
|
|
146
|
+
variants,
|
|
147
|
+
error: null,
|
|
148
|
+
},
|
|
149
|
+
}
|
|
140
150
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
151
|
+
if (newFilename !== safeFilename) {
|
|
152
|
+
updateData.filename = newFilename
|
|
153
|
+
updateData.filesize = mainSize
|
|
154
|
+
updateData.mimeType = newMimeType
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
await req.payload.update({
|
|
158
|
+
collection: input.collectionSlug as CollectionSlug,
|
|
159
|
+
id: input.docId,
|
|
160
|
+
data: updateData,
|
|
161
|
+
context: { imageOptimizer_skip: true },
|
|
162
|
+
})
|
|
163
|
+
}
|
|
147
164
|
|
|
148
165
|
return { output: { status: 'complete' } }
|
|
149
166
|
} catch (err) {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import fs from 'fs/promises'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
|
|
4
|
+
import { resolveStaticDir } from './resolveStaticDir.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Returns true when the collection uses cloud/external storage (disableLocalStorage: true).
|
|
8
|
+
* When true, files are uploaded by external adapter hooks — no local FS writes should happen.
|
|
9
|
+
*/
|
|
10
|
+
export function isCloudStorage(collectionConfig: { upload?: boolean | Record<string, any> }): boolean {
|
|
11
|
+
return typeof collectionConfig.upload === 'object' && collectionConfig.upload.disableLocalStorage === true
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Reads a file buffer from local disk or fetches it from URL.
|
|
16
|
+
* Tries local disk first (when available), falls back to URL fetch.
|
|
17
|
+
* This makes the plugin storage-agnostic — works with local FS and cloud storage alike.
|
|
18
|
+
*/
|
|
19
|
+
export async function fetchFileBuffer(
|
|
20
|
+
doc: { filename?: string; url?: string },
|
|
21
|
+
collectionConfig: { upload?: boolean | Record<string, any> },
|
|
22
|
+
): Promise<Buffer> {
|
|
23
|
+
const safeFilename = doc.filename ? path.basename(doc.filename) : undefined
|
|
24
|
+
|
|
25
|
+
// Try local disk first (only when local storage is enabled)
|
|
26
|
+
if (!isCloudStorage(collectionConfig) && safeFilename) {
|
|
27
|
+
const staticDir = resolveStaticDir(collectionConfig)
|
|
28
|
+
if (staticDir) {
|
|
29
|
+
try {
|
|
30
|
+
return await fs.readFile(path.join(staticDir, safeFilename))
|
|
31
|
+
} catch {
|
|
32
|
+
// Fall through to URL fetch
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Fetch from URL (works for cloud storage and as fallback for local)
|
|
38
|
+
if (doc.url) {
|
|
39
|
+
const url = doc.url.startsWith('http')
|
|
40
|
+
? doc.url
|
|
41
|
+
: `${process.env.NEXT_PUBLIC_SERVER_URL || ''}${doc.url}`
|
|
42
|
+
const response = await fetch(url)
|
|
43
|
+
if (!response.ok) {
|
|
44
|
+
throw new Error(`Failed to fetch file from ${url}: ${response.status} ${response.statusText}`)
|
|
45
|
+
}
|
|
46
|
+
return Buffer.from(await response.arrayBuffer())
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
throw new Error(`Cannot read file: no local path or URL available for "${doc.filename}"`)
|
|
50
|
+
}
|