@inoo-ch/payload-image-optimizer 1.3.8 → 1.4.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.
Files changed (45) hide show
  1. package/dist/components/ImageBox.d.ts +8 -0
  2. package/dist/components/OptimizationStatus.d.ts +4 -0
  3. package/dist/components/RegenerationButton.d.ts +2 -0
  4. package/dist/components/RegenerationButton.js +79 -8
  5. package/dist/components/RegenerationButton.js.map +1 -1
  6. package/dist/defaults.d.ts +3 -0
  7. package/dist/endpoints/regenerate.d.ts +4 -0
  8. package/dist/endpoints/regenerate.js +28 -14
  9. package/dist/endpoints/regenerate.js.map +1 -1
  10. package/dist/exports/client.d.ts +6 -0
  11. package/dist/exports/rsc.d.ts +1 -0
  12. package/dist/fields/imageOptimizerField.d.ts +4 -0
  13. package/dist/hooks/afterChange.d.ts +3 -0
  14. package/dist/hooks/afterChange.js +8 -42
  15. package/dist/hooks/afterChange.js.map +1 -1
  16. package/dist/hooks/beforeChange.d.ts +3 -0
  17. package/dist/hooks/beforeChange.js +14 -1
  18. package/dist/hooks/beforeChange.js.map +1 -1
  19. package/dist/index.d.ts +6 -0
  20. package/dist/processing/index.d.ts +17 -0
  21. package/dist/tasks/convertFormats.d.ts +12 -0
  22. package/dist/tasks/convertFormats.js +3 -1
  23. package/dist/tasks/convertFormats.js.map +1 -1
  24. package/dist/tasks/regenerateDocument.d.ts +18 -0
  25. package/dist/tasks/regenerateDocument.js +3 -1
  26. package/dist/tasks/regenerateDocument.js.map +1 -1
  27. package/dist/translations/index.d.ts +1 -0
  28. package/dist/types.d.ts +57 -0
  29. package/dist/utilities/getImageOptimizerProps.d.ts +24 -0
  30. package/dist/utilities/resolveStaticDir.d.ts +3 -0
  31. package/dist/utilities/storage.d.ts +18 -0
  32. package/dist/utilities/storage.js +6 -1
  33. package/dist/utilities/storage.js.map +1 -1
  34. package/dist/utilities/thumbhash.d.ts +2 -0
  35. package/dist/utilities/waitUntil.d.ts +7 -0
  36. package/dist/utilities/waitUntil.js +11 -0
  37. package/dist/utilities/waitUntil.js.map +1 -0
  38. package/package.json +1 -1
  39. package/src/endpoints/regenerate.ts +5 -2
  40. package/src/hooks/afterChange.ts +9 -41
  41. package/src/hooks/beforeChange.ts +16 -1
  42. package/src/tasks/convertFormats.ts +1 -1
  43. package/src/tasks/regenerateDocument.ts +1 -1
  44. package/src/utilities/storage.ts +8 -1
  45. package/src/utilities/waitUntil.ts +13 -0
@@ -0,0 +1,8 @@
1
+ import React from 'react';
2
+ import { type ImageProps } from 'next/image';
3
+ import type { MediaResource } from '../types.js';
4
+ export interface ImageBoxProps extends Omit<ImageProps, 'src' | 'alt'> {
5
+ media: MediaResource | string;
6
+ alt?: string;
7
+ }
8
+ export declare const ImageBox: React.FC<ImageBoxProps>;
@@ -0,0 +1,4 @@
1
+ import React from 'react';
2
+ export declare const OptimizationStatus: React.FC<{
3
+ path?: string;
4
+ }>;
@@ -0,0 +1,2 @@
1
+ import React from 'react';
2
+ export declare const RegenerationButton: React.FC;
@@ -11,6 +11,7 @@ export const RegenerationButton = ()=>{
11
11
  const [stalled, setStalled] = useState(false);
12
12
  const [collectionSlug, setCollectionSlug] = useState(null);
13
13
  const [stats, setStats] = useState(null);
14
+ const [confirming, setConfirming] = useState(false);
14
15
  const intervalRef = useRef(null);
15
16
  const stallRef = useRef({
16
17
  lastProcessed: 0,
@@ -121,9 +122,22 @@ export const RegenerationButton = ()=>{
121
122
  isRunning,
122
123
  fetchStats
123
124
  ]);
124
- const handleRegenerate = async ()=>{
125
+ // Phase 1: Show confirmation with counts
126
+ const handlePreflight = async ()=>{
125
127
  if (!collectionSlug) return;
126
128
  setError(null);
129
+ // Refresh stats to get the latest counts before confirming
130
+ await fetchStats();
131
+ setConfirming(true);
132
+ };
133
+ const handleCancel = ()=>{
134
+ setConfirming(false);
135
+ };
136
+ // Phase 2: Actually start regeneration (after user confirms)
137
+ const handleConfirm = async ()=>{
138
+ if (!collectionSlug) return;
139
+ setConfirming(false);
140
+ setError(null);
127
141
  setStalled(false);
128
142
  setIsRunning(true);
129
143
  setQueued(null);
@@ -182,8 +196,8 @@ export const RegenerationButton = ()=>{
182
196
  flexWrap: 'wrap'
183
197
  },
184
198
  children: [
185
- /*#__PURE__*/ _jsx("button", {
186
- onClick: handleRegenerate,
199
+ !confirming && /*#__PURE__*/ _jsx("button", {
200
+ onClick: handlePreflight,
187
201
  disabled: isRunning,
188
202
  style: {
189
203
  backgroundColor: isRunning ? '#9ca3af' : '#4f46e5',
@@ -195,9 +209,53 @@ export const RegenerationButton = ()=>{
195
209
  fontWeight: 500,
196
210
  cursor: isRunning ? 'not-allowed' : 'pointer'
197
211
  },
198
- children: isRunning ? 'Regenerating...' : 'Regenerate Images'
212
+ children: isRunning ? 'Processing all images...' : 'Regenerate All Images'
199
213
  }),
200
- /*#__PURE__*/ _jsxs("label", {
214
+ confirming && stats && /*#__PURE__*/ _jsxs("div", {
215
+ style: {
216
+ display: 'flex',
217
+ alignItems: 'center',
218
+ gap: '10px'
219
+ },
220
+ children: [
221
+ /*#__PURE__*/ _jsx("span", {
222
+ style: {
223
+ fontSize: '13px',
224
+ color: '#374151'
225
+ },
226
+ children: force ? `Re-process all ${stats.total} images across the entire collection?` : `Regenerate ${stats.pending} unoptimized image${stats.pending !== 1 ? 's' : ''} across the entire collection?`
227
+ }),
228
+ /*#__PURE__*/ _jsx("button", {
229
+ onClick: handleConfirm,
230
+ style: {
231
+ backgroundColor: '#4f46e5',
232
+ color: '#fff',
233
+ border: 'none',
234
+ borderRadius: '6px',
235
+ padding: '6px 14px',
236
+ fontSize: '13px',
237
+ fontWeight: 500,
238
+ cursor: 'pointer'
239
+ },
240
+ children: "Confirm"
241
+ }),
242
+ /*#__PURE__*/ _jsx("button", {
243
+ onClick: handleCancel,
244
+ style: {
245
+ backgroundColor: 'transparent',
246
+ color: '#6b7280',
247
+ border: '1px solid #d1d5db',
248
+ borderRadius: '6px',
249
+ padding: '6px 14px',
250
+ fontSize: '13px',
251
+ fontWeight: 500,
252
+ cursor: 'pointer'
253
+ },
254
+ children: "Cancel"
255
+ })
256
+ ]
257
+ }),
258
+ !confirming && /*#__PURE__*/ _jsxs("label", {
201
259
  style: {
202
260
  display: 'flex',
203
261
  alignItems: 'center',
@@ -221,7 +279,20 @@ export const RegenerationButton = ()=>{
221
279
  },
222
280
  children: error
223
281
  }),
224
- queued === 0 && !isRunning && !stalled && /*#__PURE__*/ _jsx("span", {
282
+ queued !== null && queued > 0 && isRunning && !confirming && /*#__PURE__*/ _jsxs("span", {
283
+ style: {
284
+ color: '#4f46e5',
285
+ fontSize: '13px'
286
+ },
287
+ children: [
288
+ "Queued ",
289
+ queued,
290
+ " image",
291
+ queued !== 1 ? 's' : '',
292
+ " for processing across the entire collection"
293
+ ]
294
+ }),
295
+ queued === 0 && !isRunning && !stalled && !confirming && /*#__PURE__*/ _jsx("span", {
225
296
  style: {
226
297
  color: '#10b981',
227
298
  fontSize: '13px'
@@ -311,7 +382,7 @@ export const RegenerationButton = ()=>{
311
382
  })
312
383
  ]
313
384
  }),
314
- !isRunning && progress && progress.complete > 0 && queued !== 0 && /*#__PURE__*/ _jsxs("span", {
385
+ !isRunning && progress && progress.complete > 0 && queued !== 0 && !confirming && /*#__PURE__*/ _jsxs("span", {
315
386
  style: {
316
387
  fontSize: '13px'
317
388
  },
@@ -325,7 +396,7 @@ export const RegenerationButton = ()=>{
325
396
  progress.complete,
326
397
  "/",
327
398
  progress.total,
328
- " optimized."
399
+ " optimized (across entire collection)."
329
400
  ]
330
401
  }),
331
402
  (progress.errored > 0 || stalled && progress.pending > 0) && /*#__PURE__*/ _jsxs("span", {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/RegenerationButton.tsx"],"sourcesContent":["'use client'\n\nimport React, { useState, useEffect, useCallback, useRef } from 'react'\n\ntype RegenerationProgress = {\n total: number\n complete: number\n errored: number\n pending: number\n}\n\nconst STALL_THRESHOLD = 5\n\nexport const RegenerationButton: React.FC = () => {\n const [isRunning, setIsRunning] = useState(false)\n const [progress, setProgress] = useState<RegenerationProgress | null>(null)\n const [queued, setQueued] = useState<number | null>(null)\n const [force, setForce] = useState(false)\n const [error, setError] = useState<string | null>(null)\n const [stalled, setStalled] = useState(false)\n const [collectionSlug, setCollectionSlug] = useState<string | null>(null)\n const [stats, setStats] = useState<RegenerationProgress | null>(null)\n const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)\n const stallRef = useRef({ lastProcessed: 0, stallCount: 0 })\n const prevIsRunningRef = useRef(false)\n\n // Extract collection slug from URL after mount to avoid hydration mismatch\n useEffect(() => {\n const slug = window.location.pathname.split('/collections/')[1]?.split('/')[0] ?? null\n setCollectionSlug(slug)\n }, [])\n\n // Fetch optimization stats (independent of regeneration)\n const fetchStats = useCallback(async () => {\n if (!collectionSlug) return\n try {\n const res = await fetch(\n `/api/image-optimizer/regenerate?collection=${collectionSlug}`,\n )\n if (res.ok) {\n const data: RegenerationProgress = await res.json()\n setStats(data)\n }\n } catch {\n // ignore stats fetch errors\n }\n }, [collectionSlug])\n\n const stopPolling = useCallback(() => {\n if (intervalRef.current) {\n clearInterval(intervalRef.current)\n intervalRef.current = null\n }\n }, [])\n\n const pollProgress = useCallback(async () => {\n if (!collectionSlug) return\n try {\n const res = await fetch(\n `/api/image-optimizer/regenerate?collection=${collectionSlug}`,\n )\n if (res.ok) {\n const data: RegenerationProgress = await res.json()\n setProgress(data)\n\n // Stop polling when no more pending\n if (data.pending <= 0) {\n setIsRunning(false)\n stopPolling()\n return\n }\n\n // Stall detection\n const processed = data.complete + data.errored\n if (processed === stallRef.current.lastProcessed) {\n stallRef.current.stallCount += 1\n } else {\n stallRef.current.stallCount = 0\n stallRef.current.lastProcessed = processed\n }\n\n if (stallRef.current.stallCount >= STALL_THRESHOLD) {\n stopPolling()\n setIsRunning(false)\n setStalled(true)\n }\n }\n } catch {\n // ignore polling errors\n }\n }, [collectionSlug, stopPolling])\n\n // On mount (once collectionSlug is known), check if there's an ongoing job and resume polling\n useEffect(() => {\n if (!collectionSlug) return\n let cancelled = false\n const checkOngoing = async () => {\n try {\n const res = await fetch(\n `/api/image-optimizer/regenerate?collection=${collectionSlug}`,\n )\n if (!res.ok || cancelled) return\n const data: RegenerationProgress = await res.json()\n // Always store stats on mount\n setStats(data)\n if (data.pending > 0) {\n setProgress(data)\n setIsRunning(true)\n setStalled(false)\n setQueued(null)\n stallRef.current = { lastProcessed: data.complete + data.errored, stallCount: 0 }\n intervalRef.current = setInterval(pollProgress, 2000)\n }\n } catch {\n // ignore\n }\n }\n checkOngoing()\n return () => {\n cancelled = true\n }\n }, [collectionSlug, pollProgress])\n\n // Refresh stats when regeneration finishes (isRunning transitions from true to false)\n useEffect(() => {\n if (prevIsRunningRef.current && !isRunning) {\n fetchStats()\n }\n prevIsRunningRef.current = isRunning\n }, [isRunning, fetchStats])\n\n const handleRegenerate = async () => {\n if (!collectionSlug) return\n setError(null)\n setStalled(false)\n setIsRunning(true)\n setQueued(null)\n setProgress(null)\n stallRef.current = { lastProcessed: 0, stallCount: 0 }\n\n try {\n const res = await fetch('/api/image-optimizer/regenerate', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ collectionSlug, force }),\n })\n\n if (!res.ok) {\n const data = await res.json()\n throw new Error(data.error || 'Failed to start regeneration')\n }\n\n const data = await res.json()\n setQueued(data.queued)\n\n if (data.queued === 0) {\n setIsRunning(false)\n return\n }\n\n // Start polling\n intervalRef.current = setInterval(pollProgress, 2000)\n } catch (err) {\n setError(err instanceof Error ? err.message : String(err))\n setIsRunning(false)\n }\n }\n\n // Cleanup interval on unmount\n useEffect(() => {\n return () => {\n if (intervalRef.current) clearInterval(intervalRef.current)\n }\n }, [])\n\n if (!collectionSlug) return null\n\n const progressPercent =\n progress && progress.total > 0\n ? Math.round(((progress.complete + progress.errored) / progress.total) * 100)\n : 0\n\n const showProgressBar = (isRunning && progress) || (stalled && progress)\n\n // Stats computations\n const statsPercent =\n stats && stats.total > 0\n ? Math.round((stats.complete / stats.total) * 100)\n : 0\n const allOptimized = stats && stats.total > 0 && stats.complete === stats.total\n\n return (\n <div\n style={{\n padding: '16px 24px',\n borderBottom: '1px solid #e5e7eb',\n display: 'flex',\n alignItems: 'center',\n gap: '16px',\n flexWrap: 'wrap',\n }}\n >\n <button\n onClick={handleRegenerate}\n disabled={isRunning}\n style={{\n backgroundColor: isRunning ? '#9ca3af' : '#4f46e5',\n color: '#fff',\n border: 'none',\n borderRadius: '6px',\n padding: '8px 16px',\n fontSize: '14px',\n fontWeight: 500,\n cursor: isRunning ? 'not-allowed' : 'pointer',\n }}\n >\n {isRunning ? 'Regenerating...' : 'Regenerate Images'}\n </button>\n\n <label\n style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '13px' }}\n >\n <input\n type=\"checkbox\"\n checked={force}\n onChange={(e) => setForce(e.target.checked)}\n disabled={isRunning}\n />\n Force re-process all\n </label>\n\n {error && (\n <span style={{ color: '#ef4444', fontSize: '13px' }}>{error}</span>\n )}\n\n {queued === 0 && !isRunning && !stalled && (\n <span style={{ color: '#10b981', fontSize: '13px' }}>\n All images already optimized.\n </span>\n )}\n\n {stalled && progress && (\n <span style={{ color: '#f59e0b', fontSize: '13px' }}>\n Processing finished with issues. {progress.errored + progress.pending} image\n {progress.errored + progress.pending !== 1 ? 's' : ''} failed\n {progress.pending > 0 ? ` (${progress.pending} stuck)` : ''}.\n Re-run to retry.\n </span>\n )}\n\n {showProgressBar && (\n <div style={{ flex: 1, minWidth: '200px' }}>\n <div\n style={{\n display: 'flex',\n justifyContent: 'space-between',\n fontSize: '12px',\n marginBottom: '4px',\n }}\n >\n <span>\n {progress.complete} / {progress.total} complete\n </span>\n {progress.errored > 0 && (\n <span style={{ color: '#ef4444' }}>{progress.errored} errors</span>\n )}\n <span>{progressPercent}%</span>\n </div>\n <div\n style={{\n height: '6px',\n backgroundColor: '#e5e7eb',\n borderRadius: '3px',\n overflow: 'hidden',\n display: 'flex',\n }}\n >\n <div\n style={{\n height: '100%',\n width: `${progress.total > 0 ? Math.round((progress.complete / progress.total) * 100) : 0}%`,\n backgroundColor: '#10b981',\n transition: 'width 0.3s ease',\n }}\n />\n {progress.errored > 0 && (\n <div\n style={{\n height: '100%',\n width: `${progress.total > 0 ? Math.round((progress.errored / progress.total) * 100) : 0}%`,\n backgroundColor: '#ef4444',\n transition: 'width 0.3s ease',\n }}\n />\n )}\n </div>\n </div>\n )}\n\n {!isRunning && progress && progress.complete > 0 && queued !== 0 && (\n <span style={{ fontSize: '13px' }}>\n <span style={{ color: progress.errored > 0 || stalled ? '#f59e0b' : '#10b981' }}>\n Done! {progress.complete}/{progress.total} optimized.\n </span>\n {(progress.errored > 0 || (stalled && progress.pending > 0)) && (\n <span style={{ color: '#ef4444' }}>\n {' '}{progress.errored + (stalled ? progress.pending : 0)} failed.\n </span>\n )}\n </span>\n )}\n\n {/* Persistent optimization stats — always visible when not actively regenerating */}\n {!isRunning && stats && stats.total > 0 && (\n <div\n style={{\n marginLeft: 'auto',\n display: 'flex',\n flexDirection: 'column',\n alignItems: 'flex-end',\n gap: '4px',\n minWidth: '180px',\n }}\n >\n <div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px' }}>\n {allOptimized ? (\n <span style={{ color: '#10b981' }}>\n &#10003; All {stats.total} images optimized\n </span>\n ) : (\n <>\n <span style={{ color: '#6b7280' }}>\n {stats.complete}/{stats.total} optimized\n </span>\n {stats.errored > 0 && (\n <>\n <span style={{ color: '#d1d5db' }}>&middot;</span>\n <span style={{ color: '#ef4444' }}>{stats.errored} errors</span>\n </>\n )}\n </>\n )}\n </div>\n {!allOptimized && (\n <div\n style={{\n width: '100%',\n height: '3px',\n backgroundColor: '#e5e7eb',\n borderRadius: '2px',\n overflow: 'hidden',\n }}\n >\n <div\n style={{\n height: '100%',\n width: `${statsPercent}%`,\n backgroundColor: stats.errored > 0 ? '#f59e0b' : '#10b981',\n borderRadius: '2px',\n transition: 'width 0.3s ease',\n }}\n />\n </div>\n )}\n </div>\n )}\n </div>\n )\n}\n"],"names":["React","useState","useEffect","useCallback","useRef","STALL_THRESHOLD","RegenerationButton","isRunning","setIsRunning","progress","setProgress","queued","setQueued","force","setForce","error","setError","stalled","setStalled","collectionSlug","setCollectionSlug","stats","setStats","intervalRef","stallRef","lastProcessed","stallCount","prevIsRunningRef","slug","window","location","pathname","split","fetchStats","res","fetch","ok","data","json","stopPolling","current","clearInterval","pollProgress","pending","processed","complete","errored","cancelled","checkOngoing","setInterval","handleRegenerate","method","headers","body","JSON","stringify","Error","err","message","String","progressPercent","total","Math","round","showProgressBar","statsPercent","allOptimized","div","style","padding","borderBottom","display","alignItems","gap","flexWrap","button","onClick","disabled","backgroundColor","color","border","borderRadius","fontSize","fontWeight","cursor","label","input","type","checked","onChange","e","target","span","flex","minWidth","justifyContent","marginBottom","height","overflow","width","transition","marginLeft","flexDirection"],"mappings":"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"}
1
+ {"version":3,"sources":["../../src/components/RegenerationButton.tsx"],"sourcesContent":["'use client'\n\nimport React, { useState, useEffect, useCallback, useRef } from 'react'\n\ntype RegenerationProgress = {\n total: number\n complete: number\n errored: number\n pending: number\n}\n\nconst STALL_THRESHOLD = 5\n\nexport const RegenerationButton: React.FC = () => {\n const [isRunning, setIsRunning] = useState(false)\n const [progress, setProgress] = useState<RegenerationProgress | null>(null)\n const [queued, setQueued] = useState<number | null>(null)\n const [force, setForce] = useState(false)\n const [error, setError] = useState<string | null>(null)\n const [stalled, setStalled] = useState(false)\n const [collectionSlug, setCollectionSlug] = useState<string | null>(null)\n const [stats, setStats] = useState<RegenerationProgress | null>(null)\n const [confirming, setConfirming] = useState(false)\n const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)\n const stallRef = useRef({ lastProcessed: 0, stallCount: 0 })\n const prevIsRunningRef = useRef(false)\n\n // Extract collection slug from URL after mount to avoid hydration mismatch\n useEffect(() => {\n const slug = window.location.pathname.split('/collections/')[1]?.split('/')[0] ?? null\n setCollectionSlug(slug)\n }, [])\n\n // Fetch optimization stats (independent of regeneration)\n const fetchStats = useCallback(async () => {\n if (!collectionSlug) return\n try {\n const res = await fetch(\n `/api/image-optimizer/regenerate?collection=${collectionSlug}`,\n )\n if (res.ok) {\n const data: RegenerationProgress = await res.json()\n setStats(data)\n }\n } catch {\n // ignore stats fetch errors\n }\n }, [collectionSlug])\n\n const stopPolling = useCallback(() => {\n if (intervalRef.current) {\n clearInterval(intervalRef.current)\n intervalRef.current = null\n }\n }, [])\n\n const pollProgress = useCallback(async () => {\n if (!collectionSlug) return\n try {\n const res = await fetch(\n `/api/image-optimizer/regenerate?collection=${collectionSlug}`,\n )\n if (res.ok) {\n const data: RegenerationProgress = await res.json()\n setProgress(data)\n\n // Stop polling when no more pending\n if (data.pending <= 0) {\n setIsRunning(false)\n stopPolling()\n return\n }\n\n // Stall detection\n const processed = data.complete + data.errored\n if (processed === stallRef.current.lastProcessed) {\n stallRef.current.stallCount += 1\n } else {\n stallRef.current.stallCount = 0\n stallRef.current.lastProcessed = processed\n }\n\n if (stallRef.current.stallCount >= STALL_THRESHOLD) {\n stopPolling()\n setIsRunning(false)\n setStalled(true)\n }\n }\n } catch {\n // ignore polling errors\n }\n }, [collectionSlug, stopPolling])\n\n // On mount (once collectionSlug is known), check if there's an ongoing job and resume polling\n useEffect(() => {\n if (!collectionSlug) return\n let cancelled = false\n const checkOngoing = async () => {\n try {\n const res = await fetch(\n `/api/image-optimizer/regenerate?collection=${collectionSlug}`,\n )\n if (!res.ok || cancelled) return\n const data: RegenerationProgress = await res.json()\n // Always store stats on mount\n setStats(data)\n if (data.pending > 0) {\n setProgress(data)\n setIsRunning(true)\n setStalled(false)\n setQueued(null)\n stallRef.current = { lastProcessed: data.complete + data.errored, stallCount: 0 }\n intervalRef.current = setInterval(pollProgress, 2000)\n }\n } catch {\n // ignore\n }\n }\n checkOngoing()\n return () => {\n cancelled = true\n }\n }, [collectionSlug, pollProgress])\n\n // Refresh stats when regeneration finishes (isRunning transitions from true to false)\n useEffect(() => {\n if (prevIsRunningRef.current && !isRunning) {\n fetchStats()\n }\n prevIsRunningRef.current = isRunning\n }, [isRunning, fetchStats])\n\n // Phase 1: Show confirmation with counts\n const handlePreflight = async () => {\n if (!collectionSlug) return\n setError(null)\n // Refresh stats to get the latest counts before confirming\n await fetchStats()\n setConfirming(true)\n }\n\n const handleCancel = () => {\n setConfirming(false)\n }\n\n // Phase 2: Actually start regeneration (after user confirms)\n const handleConfirm = async () => {\n if (!collectionSlug) return\n setConfirming(false)\n setError(null)\n setStalled(false)\n setIsRunning(true)\n setQueued(null)\n setProgress(null)\n stallRef.current = { lastProcessed: 0, stallCount: 0 }\n\n try {\n const res = await fetch('/api/image-optimizer/regenerate', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ collectionSlug, force }),\n })\n\n if (!res.ok) {\n const data = await res.json()\n throw new Error(data.error || 'Failed to start regeneration')\n }\n\n const data = await res.json()\n setQueued(data.queued)\n\n if (data.queued === 0) {\n setIsRunning(false)\n return\n }\n\n // Start polling\n intervalRef.current = setInterval(pollProgress, 2000)\n } catch (err) {\n setError(err instanceof Error ? err.message : String(err))\n setIsRunning(false)\n }\n }\n\n // Cleanup interval on unmount\n useEffect(() => {\n return () => {\n if (intervalRef.current) clearInterval(intervalRef.current)\n }\n }, [])\n\n if (!collectionSlug) return null\n\n const progressPercent =\n progress && progress.total > 0\n ? Math.round(((progress.complete + progress.errored) / progress.total) * 100)\n : 0\n\n const showProgressBar = (isRunning && progress) || (stalled && progress)\n\n // Stats computations\n const statsPercent =\n stats && stats.total > 0\n ? Math.round((stats.complete / stats.total) * 100)\n : 0\n const allOptimized = stats && stats.total > 0 && stats.complete === stats.total\n\n return (\n <div\n style={{\n padding: '16px 24px',\n borderBottom: '1px solid #e5e7eb',\n display: 'flex',\n alignItems: 'center',\n gap: '16px',\n flexWrap: 'wrap',\n }}\n >\n {!confirming && (\n <button\n onClick={handlePreflight}\n disabled={isRunning}\n style={{\n backgroundColor: isRunning ? '#9ca3af' : '#4f46e5',\n color: '#fff',\n border: 'none',\n borderRadius: '6px',\n padding: '8px 16px',\n fontSize: '14px',\n fontWeight: 500,\n cursor: isRunning ? 'not-allowed' : 'pointer',\n }}\n >\n {isRunning ? 'Processing all images...' : 'Regenerate All Images'}\n </button>\n )}\n\n {confirming && stats && (\n <div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>\n <span style={{ fontSize: '13px', color: '#374151' }}>\n {force\n ? `Re-process all ${stats.total} images across the entire collection?`\n : `Regenerate ${stats.pending} unoptimized image${stats.pending !== 1 ? 's' : ''} across the entire collection?`}\n </span>\n <button\n onClick={handleConfirm}\n style={{\n backgroundColor: '#4f46e5',\n color: '#fff',\n border: 'none',\n borderRadius: '6px',\n padding: '6px 14px',\n fontSize: '13px',\n fontWeight: 500,\n cursor: 'pointer',\n }}\n >\n Confirm\n </button>\n <button\n onClick={handleCancel}\n style={{\n backgroundColor: 'transparent',\n color: '#6b7280',\n border: '1px solid #d1d5db',\n borderRadius: '6px',\n padding: '6px 14px',\n fontSize: '13px',\n fontWeight: 500,\n cursor: 'pointer',\n }}\n >\n Cancel\n </button>\n </div>\n )}\n\n {!confirming && (\n <label\n style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '13px' }}\n >\n <input\n type=\"checkbox\"\n checked={force}\n onChange={(e) => setForce(e.target.checked)}\n disabled={isRunning}\n />\n Force re-process all\n </label>\n )}\n\n {error && (\n <span style={{ color: '#ef4444', fontSize: '13px' }}>{error}</span>\n )}\n\n {queued !== null && queued > 0 && isRunning && !confirming && (\n <span style={{ color: '#4f46e5', fontSize: '13px' }}>\n Queued {queued} image{queued !== 1 ? 's' : ''} for processing across the entire collection\n </span>\n )}\n\n {queued === 0 && !isRunning && !stalled && !confirming && (\n <span style={{ color: '#10b981', fontSize: '13px' }}>\n All images already optimized.\n </span>\n )}\n\n {stalled && progress && (\n <span style={{ color: '#f59e0b', fontSize: '13px' }}>\n Processing finished with issues. {progress.errored + progress.pending} image\n {progress.errored + progress.pending !== 1 ? 's' : ''} failed\n {progress.pending > 0 ? ` (${progress.pending} stuck)` : ''}.\n Re-run to retry.\n </span>\n )}\n\n {showProgressBar && (\n <div style={{ flex: 1, minWidth: '200px' }}>\n <div\n style={{\n display: 'flex',\n justifyContent: 'space-between',\n fontSize: '12px',\n marginBottom: '4px',\n }}\n >\n <span>\n {progress.complete} / {progress.total} complete\n </span>\n {progress.errored > 0 && (\n <span style={{ color: '#ef4444' }}>{progress.errored} errors</span>\n )}\n <span>{progressPercent}%</span>\n </div>\n <div\n style={{\n height: '6px',\n backgroundColor: '#e5e7eb',\n borderRadius: '3px',\n overflow: 'hidden',\n display: 'flex',\n }}\n >\n <div\n style={{\n height: '100%',\n width: `${progress.total > 0 ? Math.round((progress.complete / progress.total) * 100) : 0}%`,\n backgroundColor: '#10b981',\n transition: 'width 0.3s ease',\n }}\n />\n {progress.errored > 0 && (\n <div\n style={{\n height: '100%',\n width: `${progress.total > 0 ? Math.round((progress.errored / progress.total) * 100) : 0}%`,\n backgroundColor: '#ef4444',\n transition: 'width 0.3s ease',\n }}\n />\n )}\n </div>\n </div>\n )}\n\n {!isRunning && progress && progress.complete > 0 && queued !== 0 && !confirming && (\n <span style={{ fontSize: '13px' }}>\n <span style={{ color: progress.errored > 0 || stalled ? '#f59e0b' : '#10b981' }}>\n Done! {progress.complete}/{progress.total} optimized (across entire collection).\n </span>\n {(progress.errored > 0 || (stalled && progress.pending > 0)) && (\n <span style={{ color: '#ef4444' }}>\n {' '}{progress.errored + (stalled ? progress.pending : 0)} failed.\n </span>\n )}\n </span>\n )}\n\n {/* Persistent optimization stats — always visible when not actively regenerating */}\n {!isRunning && stats && stats.total > 0 && (\n <div\n style={{\n marginLeft: 'auto',\n display: 'flex',\n flexDirection: 'column',\n alignItems: 'flex-end',\n gap: '4px',\n minWidth: '180px',\n }}\n >\n <div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px' }}>\n {allOptimized ? (\n <span style={{ color: '#10b981' }}>\n &#10003; All {stats.total} images optimized\n </span>\n ) : (\n <>\n <span style={{ color: '#6b7280' }}>\n {stats.complete}/{stats.total} optimized\n </span>\n {stats.errored > 0 && (\n <>\n <span style={{ color: '#d1d5db' }}>&middot;</span>\n <span style={{ color: '#ef4444' }}>{stats.errored} errors</span>\n </>\n )}\n </>\n )}\n </div>\n {!allOptimized && (\n <div\n style={{\n width: '100%',\n height: '3px',\n backgroundColor: '#e5e7eb',\n borderRadius: '2px',\n overflow: 'hidden',\n }}\n >\n <div\n style={{\n height: '100%',\n width: `${statsPercent}%`,\n backgroundColor: stats.errored > 0 ? '#f59e0b' : '#10b981',\n borderRadius: '2px',\n transition: 'width 0.3s ease',\n }}\n />\n </div>\n )}\n </div>\n )}\n </div>\n )\n}\n"],"names":["React","useState","useEffect","useCallback","useRef","STALL_THRESHOLD","RegenerationButton","isRunning","setIsRunning","progress","setProgress","queued","setQueued","force","setForce","error","setError","stalled","setStalled","collectionSlug","setCollectionSlug","stats","setStats","confirming","setConfirming","intervalRef","stallRef","lastProcessed","stallCount","prevIsRunningRef","slug","window","location","pathname","split","fetchStats","res","fetch","ok","data","json","stopPolling","current","clearInterval","pollProgress","pending","processed","complete","errored","cancelled","checkOngoing","setInterval","handlePreflight","handleCancel","handleConfirm","method","headers","body","JSON","stringify","Error","err","message","String","progressPercent","total","Math","round","showProgressBar","statsPercent","allOptimized","div","style","padding","borderBottom","display","alignItems","gap","flexWrap","button","onClick","disabled","backgroundColor","color","border","borderRadius","fontSize","fontWeight","cursor","span","label","input","type","checked","onChange","e","target","flex","minWidth","justifyContent","marginBottom","height","overflow","width","transition","marginLeft","flexDirection"],"mappings":"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"}
@@ -0,0 +1,3 @@
1
+ import type { ImageOptimizerConfig, ResolvedCollectionOptimizerConfig, ResolvedImageOptimizerConfig } from './types.js';
2
+ export declare const resolveConfig: (config: ImageOptimizerConfig) => ResolvedImageOptimizerConfig;
3
+ export declare const resolveCollectionConfig: (resolvedConfig: ResolvedImageOptimizerConfig, collectionSlug: string) => ResolvedCollectionOptimizerConfig;
@@ -0,0 +1,4 @@
1
+ import type { PayloadHandler } from 'payload';
2
+ import type { ResolvedImageOptimizerConfig } from '../types.js';
3
+ export declare const createRegenerateHandler: (resolvedConfig: ResolvedImageOptimizerConfig) => PayloadHandler;
4
+ export declare const createRegenerateStatusHandler: (resolvedConfig: ResolvedImageOptimizerConfig) => PayloadHandler;
@@ -1,3 +1,4 @@
1
+ import { waitUntil } from '../utilities/waitUntil.js';
1
2
  export const createRegenerateHandler = (resolvedConfig)=>{
2
3
  const handler = async (req)=>{
3
4
  if (!req.user) {
@@ -22,26 +23,34 @@ export const createRegenerateHandler = (resolvedConfig)=>{
22
23
  });
23
24
  }
24
25
  // Find all image documents in the collection
25
- const where = {
26
+ // Unless force=true, skip already-processed docs
27
+ const where = body.force ? {
26
28
  mimeType: {
27
29
  contains: 'image/'
28
30
  }
29
- };
30
- // Unless force=true, skip already-processed docs
31
- if (!body.force) {
32
- where.or = [
31
+ } : {
32
+ and: [
33
33
  {
34
- 'imageOptimizer.status': {
35
- not_equals: 'complete'
34
+ mimeType: {
35
+ contains: 'image/'
36
36
  }
37
37
  },
38
38
  {
39
- 'imageOptimizer.status': {
40
- exists: false
41
- }
39
+ or: [
40
+ {
41
+ 'imageOptimizer.status': {
42
+ not_equals: 'complete'
43
+ }
44
+ },
45
+ {
46
+ 'imageOptimizer.status': {
47
+ exists: false
48
+ }
49
+ }
50
+ ]
42
51
  }
43
- ];
44
- }
52
+ ]
53
+ };
45
54
  let queued = 0;
46
55
  let page = 1;
47
56
  let hasMore = true;
@@ -67,13 +76,18 @@ export const createRegenerateHandler = (resolvedConfig)=>{
67
76
  hasMore = result.hasNextPage;
68
77
  page++;
69
78
  }
70
- // Fire the job runner (non-blocking)
79
+ req.payload.logger.info(`Image optimizer: queued ${queued} images from '${collectionSlug}' for regeneration`);
80
+ // Fire the job runner — use waitUntil to keep the serverless function alive
81
+ // after the response is sent, so jobs actually complete on Vercel/serverless.
71
82
  if (queued > 0) {
72
- req.payload.jobs.run().catch((err)=>{
83
+ const runPromise = req.payload.jobs.run({
84
+ limit: queued
85
+ }).catch((err)=>{
73
86
  req.payload.logger.error({
74
87
  err
75
88
  }, 'Regeneration job runner failed');
76
89
  });
90
+ waitUntil(runPromise);
77
91
  }
78
92
  return Response.json({
79
93
  queued,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/endpoints/regenerate.ts"],"sourcesContent":["import type { PayloadHandler } from 'payload'\nimport type { CollectionSlug } from 'payload'\n\nimport type { ResolvedImageOptimizerConfig } from '../types.js'\n\nexport const createRegenerateHandler = (resolvedConfig: ResolvedImageOptimizerConfig) => {\n const handler: PayloadHandler = async (req) => {\n if (!req.user) {\n return Response.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n let body: { collectionSlug?: string; force?: boolean }\n try {\n body = await req.json!()\n } catch {\n body = {}\n }\n\n const collectionSlug = body.collectionSlug\n if (!collectionSlug || !resolvedConfig.collections[collectionSlug as CollectionSlug]) {\n return Response.json(\n { error: 'Invalid or unconfigured collection slug' },\n { status: 400 },\n )\n }\n\n // Find all image documents in the collection\n const where: any = {\n mimeType: { contains: 'image/' },\n }\n // Unless force=true, skip already-processed docs\n if (!body.force) {\n where.or = [\n { 'imageOptimizer.status': { not_equals: 'complete' } },\n { 'imageOptimizer.status': { exists: false } },\n ]\n }\n\n let queued = 0\n let page = 1\n let hasMore = true\n\n while (hasMore) {\n const result = await req.payload.find({\n collection: collectionSlug as CollectionSlug,\n limit: 50,\n page,\n depth: 0,\n where,\n sort: 'createdAt',\n })\n\n for (const doc of result.docs) {\n await req.payload.jobs.queue({\n task: 'imageOptimizer_regenerateDocument',\n input: {\n collectionSlug,\n docId: String(doc.id),\n },\n })\n queued++\n }\n\n hasMore = result.hasNextPage\n page++\n }\n\n // Fire the job runner (non-blocking)\n if (queued > 0) {\n req.payload.jobs.run().catch((err: unknown) => {\n req.payload.logger.error({ err }, 'Regeneration job runner failed')\n })\n }\n\n return Response.json({ queued, collectionSlug })\n }\n\n return handler\n}\n\nexport const createRegenerateStatusHandler = (resolvedConfig: ResolvedImageOptimizerConfig) => {\n const handler: PayloadHandler = async (req) => {\n if (!req.user) {\n return Response.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n const url = new URL(req.url!)\n const collectionSlug = url.searchParams.get('collection')\n\n if (!collectionSlug || !resolvedConfig.collections[collectionSlug as CollectionSlug]) {\n return Response.json({ error: 'Invalid collection slug' }, { status: 400 })\n }\n\n const total = await req.payload.count({\n collection: collectionSlug as CollectionSlug,\n where: { mimeType: { contains: 'image/' } },\n })\n\n const complete = await req.payload.count({\n collection: collectionSlug as CollectionSlug,\n where: {\n mimeType: { contains: 'image/' },\n 'imageOptimizer.status': { equals: 'complete' },\n },\n })\n\n const errored = await req.payload.count({\n collection: collectionSlug as CollectionSlug,\n where: {\n mimeType: { contains: 'image/' },\n 'imageOptimizer.status': { equals: 'error' },\n },\n })\n\n return Response.json({\n collectionSlug,\n total: total.totalDocs,\n complete: complete.totalDocs,\n errored: errored.totalDocs,\n pending: total.totalDocs - complete.totalDocs - errored.totalDocs,\n })\n }\n\n return handler\n}\n"],"names":["createRegenerateHandler","resolvedConfig","handler","req","user","Response","json","error","status","body","collectionSlug","collections","where","mimeType","contains","force","or","not_equals","exists","queued","page","hasMore","result","payload","find","collection","limit","depth","sort","doc","docs","jobs","queue","task","input","docId","String","id","hasNextPage","run","catch","err","logger","createRegenerateStatusHandler","url","URL","searchParams","get","total","count","complete","equals","errored","totalDocs","pending"],"mappings":"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"}
1
+ {"version":3,"sources":["../../src/endpoints/regenerate.ts"],"sourcesContent":["import type { PayloadHandler } from 'payload'\nimport type { CollectionSlug, Where } from 'payload'\n\nimport type { ResolvedImageOptimizerConfig } from '../types.js'\nimport { waitUntil } from '../utilities/waitUntil.js'\n\nexport const createRegenerateHandler = (resolvedConfig: ResolvedImageOptimizerConfig) => {\n const handler: PayloadHandler = async (req) => {\n if (!req.user) {\n return Response.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n let body: { collectionSlug?: string; force?: boolean }\n try {\n body = await req.json!()\n } catch {\n body = {}\n }\n\n const collectionSlug = body.collectionSlug\n if (!collectionSlug || !resolvedConfig.collections[collectionSlug as CollectionSlug]) {\n return Response.json(\n { error: 'Invalid or unconfigured collection slug' },\n { status: 400 },\n )\n }\n\n // Find all image documents in the collection\n // Unless force=true, skip already-processed docs\n const where: Where = body.force\n ? { mimeType: { contains: 'image/' } }\n : {\n and: [\n { mimeType: { contains: 'image/' } },\n {\n or: [\n { 'imageOptimizer.status': { not_equals: 'complete' } },\n { 'imageOptimizer.status': { exists: false } },\n ],\n },\n ],\n }\n\n let queued = 0\n let page = 1\n let hasMore = true\n\n while (hasMore) {\n const result = await req.payload.find({\n collection: collectionSlug as CollectionSlug,\n limit: 50,\n page,\n depth: 0,\n where,\n sort: 'createdAt',\n })\n\n for (const doc of result.docs) {\n await req.payload.jobs.queue({\n task: 'imageOptimizer_regenerateDocument',\n input: {\n collectionSlug,\n docId: String(doc.id),\n },\n })\n queued++\n }\n\n hasMore = result.hasNextPage\n page++\n }\n\n req.payload.logger.info(`Image optimizer: queued ${queued} images from '${collectionSlug}' for regeneration`)\n\n // Fire the job runner — use waitUntil to keep the serverless function alive\n // after the response is sent, so jobs actually complete on Vercel/serverless.\n if (queued > 0) {\n const runPromise = req.payload.jobs.run({ limit: queued }).catch((err: unknown) => {\n req.payload.logger.error({ err }, 'Regeneration job runner failed')\n })\n waitUntil(runPromise)\n }\n\n return Response.json({ queued, collectionSlug })\n }\n\n return handler\n}\n\nexport const createRegenerateStatusHandler = (resolvedConfig: ResolvedImageOptimizerConfig) => {\n const handler: PayloadHandler = async (req) => {\n if (!req.user) {\n return Response.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n const url = new URL(req.url!)\n const collectionSlug = url.searchParams.get('collection')\n\n if (!collectionSlug || !resolvedConfig.collections[collectionSlug as CollectionSlug]) {\n return Response.json({ error: 'Invalid collection slug' }, { status: 400 })\n }\n\n const total = await req.payload.count({\n collection: collectionSlug as CollectionSlug,\n where: { mimeType: { contains: 'image/' } },\n })\n\n const complete = await req.payload.count({\n collection: collectionSlug as CollectionSlug,\n where: {\n mimeType: { contains: 'image/' },\n 'imageOptimizer.status': { equals: 'complete' },\n },\n })\n\n const errored = await req.payload.count({\n collection: collectionSlug as CollectionSlug,\n where: {\n mimeType: { contains: 'image/' },\n 'imageOptimizer.status': { equals: 'error' },\n },\n })\n\n return Response.json({\n collectionSlug,\n total: total.totalDocs,\n complete: complete.totalDocs,\n errored: errored.totalDocs,\n pending: total.totalDocs - complete.totalDocs - errored.totalDocs,\n })\n }\n\n return handler\n}\n"],"names":["waitUntil","createRegenerateHandler","resolvedConfig","handler","req","user","Response","json","error","status","body","collectionSlug","collections","where","force","mimeType","contains","and","or","not_equals","exists","queued","page","hasMore","result","payload","find","collection","limit","depth","sort","doc","docs","jobs","queue","task","input","docId","String","id","hasNextPage","logger","info","runPromise","run","catch","err","createRegenerateStatusHandler","url","URL","searchParams","get","total","count","complete","equals","errored","totalDocs","pending"],"mappings":"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"}
@@ -0,0 +1,6 @@
1
+ export { OptimizationStatus } from '../components/OptimizationStatus.js';
2
+ export { ImageBox } from '../components/ImageBox.js';
3
+ export type { ImageBoxProps } from '../components/ImageBox.js';
4
+ export { getImageOptimizerProps } from '../utilities/getImageOptimizerProps.js';
5
+ export type { ImageOptimizerProps } from '../utilities/getImageOptimizerProps.js';
6
+ export { RegenerationButton } from '../components/RegenerationButton.js';
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,4 @@
1
+ import type { Field, GroupField } from 'payload';
2
+ import type { FieldsOverride } from '../types.js';
3
+ export declare const defaultImageOptimizerFields: Field[];
4
+ export declare const getImageOptimizerField: (fieldsOverride?: FieldsOverride) => GroupField;
@@ -0,0 +1,3 @@
1
+ import type { CollectionAfterChangeHook } from 'payload';
2
+ import type { ResolvedImageOptimizerConfig } from '../types.js';
3
+ export declare const createAfterChangeHook: (resolvedConfig: ResolvedImageOptimizerConfig, collectionSlug: string) => CollectionAfterChangeHook;
@@ -1,8 +1,8 @@
1
1
  import fs from 'fs/promises';
2
2
  import path from 'path';
3
- import { resolveCollectionConfig } from '../defaults.js';
4
3
  import { resolveStaticDir } from '../utilities/resolveStaticDir.js';
5
4
  import { isCloudStorage } from '../utilities/storage.js';
5
+ import { waitUntil } from '../utilities/waitUntil.js';
6
6
  export const createAfterChangeHook = (resolvedConfig, collectionSlug)=>{
7
7
  return async ({ context, doc, req })=>{
8
8
  if (context?.imageOptimizer_skip) return doc;
@@ -33,46 +33,11 @@ export const createAfterChangeHook = (resolvedConfig, collectionSlug)=>{
33
33
  }
34
34
  }
35
35
  }
36
- const perCollectionConfig = resolveCollectionConfig(resolvedConfig, collectionSlug);
37
- // When replaceOriginal is on and only one format is configured, the main file
38
- // is already converted skip the async job and mark complete immediately.
39
- if (perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length <= 1) {
40
- await req.payload.update({
41
- collection: collectionSlug,
42
- id: doc.id,
43
- data: {
44
- imageOptimizer: {
45
- ...doc.imageOptimizer,
46
- status: 'complete',
47
- variants: [],
48
- error: null
49
- }
50
- },
51
- context: {
52
- imageOptimizer_skip: true
53
- }
54
- });
55
- return doc;
56
- }
57
- // With cloud storage, variant files cannot be written — skip the async job
58
- // and mark complete. CDN-level image optimization (e.g. Next.js Image) can
59
- // serve alternative formats on the fly.
60
- if (cloudStorage) {
61
- await req.payload.update({
62
- collection: collectionSlug,
63
- id: doc.id,
64
- data: {
65
- imageOptimizer: {
66
- ...doc.imageOptimizer,
67
- status: 'complete',
68
- variants: [],
69
- error: null
70
- }
71
- },
72
- context: {
73
- imageOptimizer_skip: true
74
- }
75
- });
36
+ // When status was already resolved in beforeChange (cloud storage, or
37
+ // replaceOriginal with a single format), no async job or update is needed.
38
+ // This avoids a separate update() call that fails with 404 on MongoDB due to
39
+ // transaction isolation when cloud storage adapters are involved.
40
+ if (context?.imageOptimizer_statusResolved) {
76
41
  return doc;
77
42
  }
78
43
  // Queue async format conversion job for remaining variants (local storage only)
@@ -83,11 +48,12 @@ export const createAfterChangeHook = (resolvedConfig, collectionSlug)=>{
83
48
  docId: String(doc.id)
84
49
  }
85
50
  });
86
- req.payload.jobs.run().catch((err)=>{
51
+ const runPromise = req.payload.jobs.run().catch((err)=>{
87
52
  req.payload.logger.error({
88
53
  err
89
54
  }, 'Image optimizer job runner failed');
90
55
  });
56
+ waitUntil(runPromise);
91
57
  return doc;
92
58
  };
93
59
  };
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/hooks/afterChange.ts"],"sourcesContent":["import fs from 'fs/promises'\nimport path from 'path'\nimport type { CollectionAfterChangeHook, CollectionSlug } from 'payload'\n\nimport type { ResolvedImageOptimizerConfig } from '../types.js'\nimport { resolveCollectionConfig } from '../defaults.js'\nimport { resolveStaticDir } from '../utilities/resolveStaticDir.js'\nimport { isCloudStorage } from '../utilities/storage.js'\n\nexport const createAfterChangeHook = (\n resolvedConfig: ResolvedImageOptimizerConfig,\n collectionSlug: string,\n): CollectionAfterChangeHook => {\n return async ({ context, doc, req }) => {\n if (context?.imageOptimizer_skip) return doc\n\n // Use context flag from beforeChange instead of checking req.file.data directly.\n // Cloud storage adapters may consume req.file.data in their own afterChange hook\n // before ours runs, which would cause this guard to bail out and leave status as 'pending'.\n if (!context?.imageOptimizer_hasUpload) return doc\n\n const collectionConfig = req.payload.collections[collectionSlug as keyof typeof req.payload.collections].config\n const cloudStorage = isCloudStorage(collectionConfig)\n\n // When using local storage, overwrite the file on disk with the processed buffer.\n // Payload's uploadFiles step writes the original buffer; we replace it here.\n // When using cloud storage, skip — the cloud adapter's afterChange hook already\n // uploads the correct buffer from req.file.data (set in our beforeChange hook).\n if (!cloudStorage) {\n const staticDir = resolveStaticDir(collectionConfig)\n const processedBuffer = context.imageOptimizer_processedBuffer as Buffer | undefined\n if (processedBuffer && doc.filename && staticDir) {\n const safeFilename = path.basename(doc.filename as string)\n const filePath = path.join(staticDir, safeFilename)\n await fs.writeFile(filePath, processedBuffer)\n\n // If replaceOriginal changed the filename, clean up the old file Payload wrote\n const originalFilename = context.imageOptimizer_originalFilename as string | undefined\n if (originalFilename && originalFilename !== safeFilename) {\n const oldFilePath = path.join(staticDir, path.basename(originalFilename))\n await fs.unlink(oldFilePath).catch(() => {\n // Old file may not exist if Payload used the new filename\n })\n }\n }\n }\n\n const perCollectionConfig = resolveCollectionConfig(resolvedConfig, collectionSlug)\n\n // When replaceOriginal is on and only one format is configured, the main file\n // is already converted skip the async job and mark complete immediately.\n if (perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length <= 1) {\n await req.payload.update({\n collection: collectionSlug as CollectionSlug,\n id: doc.id,\n data: {\n imageOptimizer: {\n ...doc.imageOptimizer,\n status: 'complete',\n variants: [],\n error: null,\n },\n },\n context: { imageOptimizer_skip: true },\n })\n return doc\n }\n\n // With cloud storage, variant files cannot be written — skip the async job\n // and mark complete. CDN-level image optimization (e.g. Next.js Image) can\n // serve alternative formats on the fly.\n if (cloudStorage) {\n await req.payload.update({\n collection: collectionSlug as CollectionSlug,\n id: doc.id,\n data: {\n imageOptimizer: {\n ...doc.imageOptimizer,\n status: 'complete',\n variants: [],\n error: null,\n },\n },\n context: { imageOptimizer_skip: true },\n })\n return doc\n }\n\n // Queue async format conversion job for remaining variants (local storage only)\n await req.payload.jobs.queue({\n task: 'imageOptimizer_convertFormats',\n input: {\n collectionSlug,\n docId: String(doc.id),\n },\n })\n\n req.payload.jobs.run().catch((err: unknown) => {\n req.payload.logger.error({ err }, 'Image optimizer job runner failed')\n })\n\n return doc\n }\n}\n"],"names":["fs","path","resolveCollectionConfig","resolveStaticDir","isCloudStorage","createAfterChangeHook","resolvedConfig","collectionSlug","context","doc","req","imageOptimizer_skip","imageOptimizer_hasUpload","collectionConfig","payload","collections","config","cloudStorage","staticDir","processedBuffer","imageOptimizer_processedBuffer","filename","safeFilename","basename","filePath","join","writeFile","originalFilename","imageOptimizer_originalFilename","oldFilePath","unlink","catch","perCollectionConfig","replaceOriginal","formats","length","update","collection","id","data","imageOptimizer","status","variants","error","jobs","queue","task","input","docId","String","run","err","logger"],"mappings":"AAAA,OAAOA,QAAQ,cAAa;AAC5B,OAAOC,UAAU,OAAM;AAIvB,SAASC,uBAAuB,QAAQ,iBAAgB;AACxD,SAASC,gBAAgB,QAAQ,mCAAkC;AACnE,SAASC,cAAc,QAAQ,0BAAyB;AAExD,OAAO,MAAMC,wBAAwB,CACnCC,gBACAC;IAEA,OAAO,OAAO,EAAEC,OAAO,EAAEC,GAAG,EAAEC,GAAG,EAAE;QACjC,IAAIF,SAASG,qBAAqB,OAAOF;QAEzC,iFAAiF;QACjF,iFAAiF;QACjF,4FAA4F;QAC5F,IAAI,CAACD,SAASI,0BAA0B,OAAOH;QAE/C,MAAMI,mBAAmBH,IAAII,OAAO,CAACC,WAAW,CAACR,eAAuD,CAACS,MAAM;QAC/G,MAAMC,eAAeb,eAAeS;QAEpC,kFAAkF;QAClF,6EAA6E;QAC7E,gFAAgF;QAChF,gFAAgF;QAChF,IAAI,CAACI,cAAc;YACjB,MAAMC,YAAYf,iBAAiBU;YACnC,MAAMM,kBAAkBX,QAAQY,8BAA8B;YAC9D,IAAID,mBAAmBV,IAAIY,QAAQ,IAAIH,WAAW;gBAChD,MAAMI,eAAerB,KAAKsB,QAAQ,CAACd,IAAIY,QAAQ;gBAC/C,MAAMG,WAAWvB,KAAKwB,IAAI,CAACP,WAAWI;gBACtC,MAAMtB,GAAG0B,SAAS,CAACF,UAAUL;gBAE7B,+EAA+E;gBAC/E,MAAMQ,mBAAmBnB,QAAQoB,+BAA+B;gBAChE,IAAID,oBAAoBA,qBAAqBL,cAAc;oBACzD,MAAMO,cAAc5B,KAAKwB,IAAI,CAACP,WAAWjB,KAAKsB,QAAQ,CAACI;oBACvD,MAAM3B,GAAG8B,MAAM,CAACD,aAAaE,KAAK,CAAC;oBACjC,0DAA0D;oBAC5D;gBACF;YACF;QACF;QAEA,MAAMC,sBAAsB9B,wBAAwBI,gBAAgBC;QAEpE,8EAA8E;QAC9E,2EAA2E;QAC3E,IAAIyB,oBAAoBC,eAAe,IAAID,oBAAoBE,OAAO,CAACC,MAAM,IAAI,GAAG;YAClF,MAAMzB,IAAII,OAAO,CAACsB,MAAM,CAAC;gBACvBC,YAAY9B;gBACZ+B,IAAI7B,IAAI6B,EAAE;gBACVC,MAAM;oBACJC,gBAAgB;wBACd,GAAG/B,IAAI+B,cAAc;wBACrBC,QAAQ;wBACRC,UAAU,EAAE;wBACZC,OAAO;oBACT;gBACF;gBACAnC,SAAS;oBAAEG,qBAAqB;gBAAK;YACvC;YACA,OAAOF;QACT;QAEA,2EAA2E;QAC3E,2EAA2E;QAC3E,wCAAwC;QACxC,IAAIQ,cAAc;YAChB,MAAMP,IAAII,OAAO,CAACsB,MAAM,CAAC;gBACvBC,YAAY9B;gBACZ+B,IAAI7B,IAAI6B,EAAE;gBACVC,MAAM;oBACJC,gBAAgB;wBACd,GAAG/B,IAAI+B,cAAc;wBACrBC,QAAQ;wBACRC,UAAU,EAAE;wBACZC,OAAO;oBACT;gBACF;gBACAnC,SAAS;oBAAEG,qBAAqB;gBAAK;YACvC;YACA,OAAOF;QACT;QAEA,gFAAgF;QAChF,MAAMC,IAAII,OAAO,CAAC8B,IAAI,CAACC,KAAK,CAAC;YAC3BC,MAAM;YACNC,OAAO;gBACLxC;gBACAyC,OAAOC,OAAOxC,IAAI6B,EAAE;YACtB;QACF;QAEA5B,IAAII,OAAO,CAAC8B,IAAI,CAACM,GAAG,GAAGnB,KAAK,CAAC,CAACoB;YAC5BzC,IAAII,OAAO,CAACsC,MAAM,CAACT,KAAK,CAAC;gBAAEQ;YAAI,GAAG;QACpC;QAEA,OAAO1C;IACT;AACF,EAAC"}
1
+ {"version":3,"sources":["../../src/hooks/afterChange.ts"],"sourcesContent":["import fs from 'fs/promises'\nimport path from 'path'\nimport type { CollectionAfterChangeHook } from 'payload'\n\nimport type { ResolvedImageOptimizerConfig } from '../types.js'\nimport { resolveStaticDir } from '../utilities/resolveStaticDir.js'\nimport { isCloudStorage } from '../utilities/storage.js'\nimport { waitUntil } from '../utilities/waitUntil.js'\n\nexport const createAfterChangeHook = (\n resolvedConfig: ResolvedImageOptimizerConfig,\n collectionSlug: string,\n): CollectionAfterChangeHook => {\n return async ({ context, doc, req }) => {\n if (context?.imageOptimizer_skip) return doc\n\n // Use context flag from beforeChange instead of checking req.file.data directly.\n // Cloud storage adapters may consume req.file.data in their own afterChange hook\n // before ours runs, which would cause this guard to bail out and leave status as 'pending'.\n if (!context?.imageOptimizer_hasUpload) return doc\n\n const collectionConfig = req.payload.collections[collectionSlug as keyof typeof req.payload.collections].config\n const cloudStorage = isCloudStorage(collectionConfig)\n\n // When using local storage, overwrite the file on disk with the processed buffer.\n // Payload's uploadFiles step writes the original buffer; we replace it here.\n // When using cloud storage, skip — the cloud adapter's afterChange hook already\n // uploads the correct buffer from req.file.data (set in our beforeChange hook).\n if (!cloudStorage) {\n const staticDir = resolveStaticDir(collectionConfig)\n const processedBuffer = context.imageOptimizer_processedBuffer as Buffer | undefined\n if (processedBuffer && doc.filename && staticDir) {\n const safeFilename = path.basename(doc.filename as string)\n const filePath = path.join(staticDir, safeFilename)\n await fs.writeFile(filePath, processedBuffer)\n\n // If replaceOriginal changed the filename, clean up the old file Payload wrote\n const originalFilename = context.imageOptimizer_originalFilename as string | undefined\n if (originalFilename && originalFilename !== safeFilename) {\n const oldFilePath = path.join(staticDir, path.basename(originalFilename))\n await fs.unlink(oldFilePath).catch(() => {\n // Old file may not exist if Payload used the new filename\n })\n }\n }\n }\n\n // When status was already resolved in beforeChange (cloud storage, or\n // replaceOriginal with a single format), no async job or update is needed.\n // This avoids a separate update() call that fails with 404 on MongoDB due to\n // transaction isolation when cloud storage adapters are involved.\n if (context?.imageOptimizer_statusResolved) {\n return doc\n }\n\n // Queue async format conversion job for remaining variants (local storage only)\n await req.payload.jobs.queue({\n task: 'imageOptimizer_convertFormats',\n input: {\n collectionSlug,\n docId: String(doc.id),\n },\n })\n\n const runPromise = req.payload.jobs.run().catch((err: unknown) => {\n req.payload.logger.error({ err }, 'Image optimizer job runner failed')\n })\n waitUntil(runPromise)\n\n return doc\n }\n}\n"],"names":["fs","path","resolveStaticDir","isCloudStorage","waitUntil","createAfterChangeHook","resolvedConfig","collectionSlug","context","doc","req","imageOptimizer_skip","imageOptimizer_hasUpload","collectionConfig","payload","collections","config","cloudStorage","staticDir","processedBuffer","imageOptimizer_processedBuffer","filename","safeFilename","basename","filePath","join","writeFile","originalFilename","imageOptimizer_originalFilename","oldFilePath","unlink","catch","imageOptimizer_statusResolved","jobs","queue","task","input","docId","String","id","runPromise","run","err","logger","error"],"mappings":"AAAA,OAAOA,QAAQ,cAAa;AAC5B,OAAOC,UAAU,OAAM;AAIvB,SAASC,gBAAgB,QAAQ,mCAAkC;AACnE,SAASC,cAAc,QAAQ,0BAAyB;AACxD,SAASC,SAAS,QAAQ,4BAA2B;AAErD,OAAO,MAAMC,wBAAwB,CACnCC,gBACAC;IAEA,OAAO,OAAO,EAAEC,OAAO,EAAEC,GAAG,EAAEC,GAAG,EAAE;QACjC,IAAIF,SAASG,qBAAqB,OAAOF;QAEzC,iFAAiF;QACjF,iFAAiF;QACjF,4FAA4F;QAC5F,IAAI,CAACD,SAASI,0BAA0B,OAAOH;QAE/C,MAAMI,mBAAmBH,IAAII,OAAO,CAACC,WAAW,CAACR,eAAuD,CAACS,MAAM;QAC/G,MAAMC,eAAed,eAAeU;QAEpC,kFAAkF;QAClF,6EAA6E;QAC7E,gFAAgF;QAChF,gFAAgF;QAChF,IAAI,CAACI,cAAc;YACjB,MAAMC,YAAYhB,iBAAiBW;YACnC,MAAMM,kBAAkBX,QAAQY,8BAA8B;YAC9D,IAAID,mBAAmBV,IAAIY,QAAQ,IAAIH,WAAW;gBAChD,MAAMI,eAAerB,KAAKsB,QAAQ,CAACd,IAAIY,QAAQ;gBAC/C,MAAMG,WAAWvB,KAAKwB,IAAI,CAACP,WAAWI;gBACtC,MAAMtB,GAAG0B,SAAS,CAACF,UAAUL;gBAE7B,+EAA+E;gBAC/E,MAAMQ,mBAAmBnB,QAAQoB,+BAA+B;gBAChE,IAAID,oBAAoBA,qBAAqBL,cAAc;oBACzD,MAAMO,cAAc5B,KAAKwB,IAAI,CAACP,WAAWjB,KAAKsB,QAAQ,CAACI;oBACvD,MAAM3B,GAAG8B,MAAM,CAACD,aAAaE,KAAK,CAAC;oBACjC,0DAA0D;oBAC5D;gBACF;YACF;QACF;QAEA,sEAAsE;QACtE,2EAA2E;QAC3E,6EAA6E;QAC7E,kEAAkE;QAClE,IAAIvB,SAASwB,+BAA+B;YAC1C,OAAOvB;QACT;QAEA,gFAAgF;QAChF,MAAMC,IAAII,OAAO,CAACmB,IAAI,CAACC,KAAK,CAAC;YAC3BC,MAAM;YACNC,OAAO;gBACL7B;gBACA8B,OAAOC,OAAO7B,IAAI8B,EAAE;YACtB;QACF;QAEA,MAAMC,aAAa9B,IAAII,OAAO,CAACmB,IAAI,CAACQ,GAAG,GAAGV,KAAK,CAAC,CAACW;YAC/ChC,IAAII,OAAO,CAAC6B,MAAM,CAACC,KAAK,CAAC;gBAAEF;YAAI,GAAG;QACpC;QACAtC,UAAUoC;QAEV,OAAO/B;IACT;AACF,EAAC"}
@@ -0,0 +1,3 @@
1
+ import type { CollectionBeforeChangeHook } from 'payload';
2
+ import type { ResolvedImageOptimizerConfig } from '../types.js';
3
+ export declare const createBeforeChangeHook: (resolvedConfig: ResolvedImageOptimizerConfig, collectionSlug: string) => CollectionBeforeChangeHook;
@@ -1,6 +1,7 @@
1
1
  import path from 'path';
2
2
  import { resolveCollectionConfig } from '../defaults.js';
3
3
  import { convertFormat, generateThumbHash, stripAndResize } from '../processing/index.js';
4
+ import { isCloudStorage } from '../utilities/storage.js';
4
5
  export const createBeforeChangeHook = (resolvedConfig, collectionSlug)=>{
5
6
  return async ({ context, data, req })=>{
6
7
  if (context?.imageOptimizer_skip) return data;
@@ -25,11 +26,23 @@ export const createBeforeChangeHook = (resolvedConfig, collectionSlug)=>{
25
26
  data.mimeType = converted.mimeType;
26
27
  data.filesize = finalSize;
27
28
  }
29
+ // Determine if async work (variant generation job) is needed after create.
30
+ // If not, set status to 'complete' now so afterChange doesn't need a separate
31
+ // update() call — which fails with 404 on MongoDB due to transaction isolation
32
+ // when cloud storage adapters are involved.
33
+ const collectionConfig = req.payload.collections[collectionSlug].config;
34
+ const cloudStorage = isCloudStorage(collectionConfig);
35
+ const needsAsyncJob = !cloudStorage && perCollectionConfig.formats.length > 0 && !(perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length <= 1);
28
36
  data.imageOptimizer = {
29
37
  originalSize,
30
38
  optimizedSize: finalSize,
31
- status: 'pending'
39
+ status: needsAsyncJob ? 'pending' : 'complete',
40
+ variants: needsAsyncJob ? undefined : [],
41
+ error: null
32
42
  };
43
+ if (!needsAsyncJob) {
44
+ context.imageOptimizer_statusResolved = true;
45
+ }
33
46
  if (resolvedConfig.generateThumbHash) {
34
47
  data.imageOptimizer.thumbHash = await generateThumbHash(finalBuffer);
35
48
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/hooks/beforeChange.ts"],"sourcesContent":["import path from 'path'\nimport type { CollectionBeforeChangeHook } from 'payload'\n\nimport type { ResolvedImageOptimizerConfig } from '../types.js'\nimport { resolveCollectionConfig } from '../defaults.js'\nimport { convertFormat, generateThumbHash, stripAndResize } from '../processing/index.js'\n\nexport const createBeforeChangeHook = (\n resolvedConfig: ResolvedImageOptimizerConfig,\n collectionSlug: string,\n): CollectionBeforeChangeHook => {\n return async ({ context, data, req }) => {\n if (context?.imageOptimizer_skip) return data\n\n if (!req.file || !req.file.data || !req.file.mimetype?.startsWith('image/')) return data\n\n const originalSize = req.file.data.length\n\n const perCollectionConfig = resolveCollectionConfig(resolvedConfig, collectionSlug)\n\n // Process in memory: strip EXIF, resize, generate blur\n const processed = await stripAndResize(\n req.file.data,\n perCollectionConfig.maxDimensions,\n resolvedConfig.stripMetadata,\n )\n\n let finalBuffer = processed.buffer\n let finalSize = processed.size\n\n if (perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0) {\n // Convert to primary format (first in the formats array)\n const primaryFormat = perCollectionConfig.formats[0]\n const converted = await convertFormat(processed.buffer, primaryFormat.format, primaryFormat.quality)\n\n finalBuffer = converted.buffer\n finalSize = converted.size\n\n // Update filename and mimeType so Payload stores the correct metadata\n const originalFilename = data.filename || req.file.name || ''\n const newFilename = `${path.parse(originalFilename).name}.${primaryFormat.format}`\n context.imageOptimizer_originalFilename = originalFilename\n data.filename = newFilename\n data.mimeType = converted.mimeType\n data.filesize = finalSize\n }\n\n data.imageOptimizer = {\n originalSize,\n optimizedSize: finalSize,\n status: 'pending',\n }\n\n if (resolvedConfig.generateThumbHash) {\n data.imageOptimizer.thumbHash = await generateThumbHash(finalBuffer)\n }\n\n // Write processed buffer back to req.file so cloud storage adapters\n // (which read req.file in their afterChange hook) upload the optimized version.\n // Payload's own uploadFiles step does NOT re-read req.file.data for its local\n // disk write, so we also store the buffer in context for our afterChange hook\n // to overwrite the local file when local storage is enabled.\n req.file.data = finalBuffer\n req.file.size = finalSize\n if (perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0) {\n req.file.name = data.filename\n req.file.mimetype = data.mimeType\n }\n context.imageOptimizer_processedBuffer = finalBuffer\n context.imageOptimizer_hasUpload = true\n\n return data\n }\n}\n"],"names":["path","resolveCollectionConfig","convertFormat","generateThumbHash","stripAndResize","createBeforeChangeHook","resolvedConfig","collectionSlug","context","data","req","imageOptimizer_skip","file","mimetype","startsWith","originalSize","length","perCollectionConfig","processed","maxDimensions","stripMetadata","finalBuffer","buffer","finalSize","size","replaceOriginal","formats","primaryFormat","converted","format","quality","originalFilename","filename","name","newFilename","parse","imageOptimizer_originalFilename","mimeType","filesize","imageOptimizer","optimizedSize","status","thumbHash","imageOptimizer_processedBuffer","imageOptimizer_hasUpload"],"mappings":"AAAA,OAAOA,UAAU,OAAM;AAIvB,SAASC,uBAAuB,QAAQ,iBAAgB;AACxD,SAASC,aAAa,EAAEC,iBAAiB,EAAEC,cAAc,QAAQ,yBAAwB;AAEzF,OAAO,MAAMC,yBAAyB,CACpCC,gBACAC;IAEA,OAAO,OAAO,EAAEC,OAAO,EAAEC,IAAI,EAAEC,GAAG,EAAE;QAClC,IAAIF,SAASG,qBAAqB,OAAOF;QAEzC,IAAI,CAACC,IAAIE,IAAI,IAAI,CAACF,IAAIE,IAAI,CAACH,IAAI,IAAI,CAACC,IAAIE,IAAI,CAACC,QAAQ,EAAEC,WAAW,WAAW,OAAOL;QAEpF,MAAMM,eAAeL,IAAIE,IAAI,CAACH,IAAI,CAACO,MAAM;QAEzC,MAAMC,sBAAsBhB,wBAAwBK,gBAAgBC;QAEpE,uDAAuD;QACvD,MAAMW,YAAY,MAAMd,eACtBM,IAAIE,IAAI,CAACH,IAAI,EACbQ,oBAAoBE,aAAa,EACjCb,eAAec,aAAa;QAG9B,IAAIC,cAAcH,UAAUI,MAAM;QAClC,IAAIC,YAAYL,UAAUM,IAAI;QAE9B,IAAIP,oBAAoBQ,eAAe,IAAIR,oBAAoBS,OAAO,CAACV,MAAM,GAAG,GAAG;YACjF,yDAAyD;YACzD,MAAMW,gBAAgBV,oBAAoBS,OAAO,CAAC,EAAE;YACpD,MAAME,YAAY,MAAM1B,cAAcgB,UAAUI,MAAM,EAAEK,cAAcE,MAAM,EAAEF,cAAcG,OAAO;YAEnGT,cAAcO,UAAUN,MAAM;YAC9BC,YAAYK,UAAUJ,IAAI;YAE1B,sEAAsE;YACtE,MAAMO,mBAAmBtB,KAAKuB,QAAQ,IAAItB,IAAIE,IAAI,CAACqB,IAAI,IAAI;YAC3D,MAAMC,cAAc,GAAGlC,KAAKmC,KAAK,CAACJ,kBAAkBE,IAAI,CAAC,CAAC,EAAEN,cAAcE,MAAM,EAAE;YAClFrB,QAAQ4B,+BAA+B,GAAGL;YAC1CtB,KAAKuB,QAAQ,GAAGE;YAChBzB,KAAK4B,QAAQ,GAAGT,UAAUS,QAAQ;YAClC5B,KAAK6B,QAAQ,GAAGf;QAClB;QAEAd,KAAK8B,cAAc,GAAG;YACpBxB;YACAyB,eAAejB;YACfkB,QAAQ;QACV;QAEA,IAAInC,eAAeH,iBAAiB,EAAE;YACpCM,KAAK8B,cAAc,CAACG,SAAS,GAAG,MAAMvC,kBAAkBkB;QAC1D;QAEA,oEAAoE;QACpE,gFAAgF;QAChF,8EAA8E;QAC9E,8EAA8E;QAC9E,6DAA6D;QAC7DX,IAAIE,IAAI,CAACH,IAAI,GAAGY;QAChBX,IAAIE,IAAI,CAACY,IAAI,GAAGD;QAChB,IAAIN,oBAAoBQ,eAAe,IAAIR,oBAAoBS,OAAO,CAACV,MAAM,GAAG,GAAG;YACjFN,IAAIE,IAAI,CAACqB,IAAI,GAAGxB,KAAKuB,QAAQ;YAC7BtB,IAAIE,IAAI,CAACC,QAAQ,GAAGJ,KAAK4B,QAAQ;QACnC;QACA7B,QAAQmC,8BAA8B,GAAGtB;QACzCb,QAAQoC,wBAAwB,GAAG;QAEnC,OAAOnC;IACT;AACF,EAAC"}
1
+ {"version":3,"sources":["../../src/hooks/beforeChange.ts"],"sourcesContent":["import path from 'path'\nimport type { CollectionBeforeChangeHook } from 'payload'\n\nimport type { ResolvedImageOptimizerConfig } from '../types.js'\nimport { resolveCollectionConfig } from '../defaults.js'\nimport { convertFormat, generateThumbHash, stripAndResize } from '../processing/index.js'\nimport { isCloudStorage } from '../utilities/storage.js'\n\nexport const createBeforeChangeHook = (\n resolvedConfig: ResolvedImageOptimizerConfig,\n collectionSlug: string,\n): CollectionBeforeChangeHook => {\n return async ({ context, data, req }) => {\n if (context?.imageOptimizer_skip) return data\n\n if (!req.file || !req.file.data || !req.file.mimetype?.startsWith('image/')) return data\n\n const originalSize = req.file.data.length\n\n const perCollectionConfig = resolveCollectionConfig(resolvedConfig, collectionSlug)\n\n // Process in memory: strip EXIF, resize, generate blur\n const processed = await stripAndResize(\n req.file.data,\n perCollectionConfig.maxDimensions,\n resolvedConfig.stripMetadata,\n )\n\n let finalBuffer = processed.buffer\n let finalSize = processed.size\n\n if (perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0) {\n // Convert to primary format (first in the formats array)\n const primaryFormat = perCollectionConfig.formats[0]\n const converted = await convertFormat(processed.buffer, primaryFormat.format, primaryFormat.quality)\n\n finalBuffer = converted.buffer\n finalSize = converted.size\n\n // Update filename and mimeType so Payload stores the correct metadata\n const originalFilename = data.filename || req.file.name || ''\n const newFilename = `${path.parse(originalFilename).name}.${primaryFormat.format}`\n context.imageOptimizer_originalFilename = originalFilename\n data.filename = newFilename\n data.mimeType = converted.mimeType\n data.filesize = finalSize\n }\n\n // Determine if async work (variant generation job) is needed after create.\n // If not, set status to 'complete' now so afterChange doesn't need a separate\n // update() call — which fails with 404 on MongoDB due to transaction isolation\n // when cloud storage adapters are involved.\n const collectionConfig = req.payload.collections[collectionSlug as keyof typeof req.payload.collections].config\n const cloudStorage = isCloudStorage(collectionConfig)\n const needsAsyncJob = !cloudStorage && perCollectionConfig.formats.length > 0 && !(perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length <= 1)\n\n data.imageOptimizer = {\n originalSize,\n optimizedSize: finalSize,\n status: needsAsyncJob ? 'pending' : 'complete',\n variants: needsAsyncJob ? undefined : [],\n error: null,\n }\n\n if (!needsAsyncJob) {\n context.imageOptimizer_statusResolved = true\n }\n\n if (resolvedConfig.generateThumbHash) {\n data.imageOptimizer.thumbHash = await generateThumbHash(finalBuffer)\n }\n\n // Write processed buffer back to req.file so cloud storage adapters\n // (which read req.file in their afterChange hook) upload the optimized version.\n // Payload's own uploadFiles step does NOT re-read req.file.data for its local\n // disk write, so we also store the buffer in context for our afterChange hook\n // to overwrite the local file when local storage is enabled.\n req.file.data = finalBuffer\n req.file.size = finalSize\n if (perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0) {\n req.file.name = data.filename\n req.file.mimetype = data.mimeType\n }\n context.imageOptimizer_processedBuffer = finalBuffer\n context.imageOptimizer_hasUpload = true\n\n return data\n }\n}\n"],"names":["path","resolveCollectionConfig","convertFormat","generateThumbHash","stripAndResize","isCloudStorage","createBeforeChangeHook","resolvedConfig","collectionSlug","context","data","req","imageOptimizer_skip","file","mimetype","startsWith","originalSize","length","perCollectionConfig","processed","maxDimensions","stripMetadata","finalBuffer","buffer","finalSize","size","replaceOriginal","formats","primaryFormat","converted","format","quality","originalFilename","filename","name","newFilename","parse","imageOptimizer_originalFilename","mimeType","filesize","collectionConfig","payload","collections","config","cloudStorage","needsAsyncJob","imageOptimizer","optimizedSize","status","variants","undefined","error","imageOptimizer_statusResolved","thumbHash","imageOptimizer_processedBuffer","imageOptimizer_hasUpload"],"mappings":"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"}
@@ -0,0 +1,6 @@
1
+ import type { Config } from 'payload';
2
+ import type { ImageOptimizerConfig } from './types.js';
3
+ export type { ImageOptimizerConfig, ImageFormat, FormatQuality, CollectionOptimizerConfig, ImageOptimizerData, MediaResource, FieldsOverride } from './types.js';
4
+ export { defaultImageOptimizerFields } from './fields/imageOptimizerField.js';
5
+ export { encodeImageToThumbHash, decodeThumbHashToDataURL } from './utilities/thumbhash.js';
6
+ export declare const imageOptimizer: (pluginOptions: ImageOptimizerConfig) => (config: Config) => Config;
@@ -0,0 +1,17 @@
1
+ export declare function stripAndResize(buffer: Buffer, maxDimensions: {
2
+ width: number;
3
+ height: number;
4
+ }, stripMetadata: boolean): Promise<{
5
+ buffer: Buffer;
6
+ width: number;
7
+ height: number;
8
+ size: number;
9
+ }>;
10
+ export declare function generateThumbHash(buffer: Buffer): Promise<string>;
11
+ export declare function convertFormat(buffer: Buffer, format: 'webp' | 'avif', quality: number): Promise<{
12
+ buffer: Buffer;
13
+ width: number;
14
+ height: number;
15
+ size: number;
16
+ mimeType: string;
17
+ }>;
@@ -0,0 +1,12 @@
1
+ import type { ResolvedImageOptimizerConfig } from '../types.js';
2
+ export declare const createConvertFormatsHandler: (resolvedConfig: ResolvedImageOptimizerConfig) => ({ input, req }: {
3
+ input: {
4
+ collectionSlug: string;
5
+ docId: string;
6
+ };
7
+ req: any;
8
+ }) => Promise<{
9
+ output: {
10
+ variantsGenerated: number;
11
+ };
12
+ }>;
@@ -100,7 +100,9 @@ export const createConvertFormatsHandler = (resolvedConfig)=>{
100
100
  });
101
101
  } catch (updateErr) {
102
102
  req.payload.logger.error({
103
- err: updateErr
103
+ err: updateErr,
104
+ docId: input.docId,
105
+ collectionSlug: input.collectionSlug
104
106
  }, 'Failed to persist error status for image optimizer');
105
107
  }
106
108
  throw err;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/tasks/convertFormats.ts"],"sourcesContent":["import fs from 'fs/promises'\nimport path from 'path'\n\nimport type { CollectionSlug } from 'payload'\n\nimport type { ResolvedImageOptimizerConfig } from '../types.js'\nimport { resolveCollectionConfig } from '../defaults.js'\nimport { convertFormat } from '../processing/index.js'\nimport { resolveStaticDir } from '../utilities/resolveStaticDir.js'\nimport { fetchFileBuffer, isCloudStorage } from '../utilities/storage.js'\n\nexport const createConvertFormatsHandler = (resolvedConfig: ResolvedImageOptimizerConfig) => {\n return async ({ input, req }: { input: { collectionSlug: string; docId: string }; req: any }) => {\n try {\n const doc = await req.payload.findByID({\n collection: input.collectionSlug as CollectionSlug,\n id: input.docId,\n })\n\n const collectionConfig = req.payload.collections[input.collectionSlug as keyof typeof req.payload.collections].config\n const cloudStorage = isCloudStorage(collectionConfig)\n\n // Cloud storage: variant files cannot be uploaded without direct adapter access.\n // Mark as complete — CDN-level image optimization handles format conversion.\n if (cloudStorage) {\n await req.payload.update({\n collection: input.collectionSlug as CollectionSlug,\n id: input.docId,\n data: {\n imageOptimizer: {\n ...doc.imageOptimizer,\n status: 'complete',\n variants: [],\n error: null,\n },\n },\n context: { imageOptimizer_skip: true },\n })\n return { output: { variantsGenerated: 0 } }\n }\n\n const staticDir = resolveStaticDir(collectionConfig)\n if (!staticDir) {\n throw new Error(`No staticDir configured for collection \"${input.collectionSlug}\"`)\n }\n\n const fileBuffer = await fetchFileBuffer(doc, collectionConfig)\n\n const variants: Array<{\n filename: string\n filesize: number\n format: string\n height: number\n mimeType: string\n url: string\n width: number\n }> = []\n\n const perCollectionConfig = resolveCollectionConfig(resolvedConfig, input.collectionSlug)\n\n // When replaceOriginal is on, the main file is already in the primary format —\n // skip it and only generate variants for the remaining formats.\n const formatsToGenerate = perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0\n ? perCollectionConfig.formats.slice(1)\n : perCollectionConfig.formats\n\n const safeFilename = path.basename(doc.filename)\n\n for (const format of formatsToGenerate) {\n const result = await convertFormat(fileBuffer, format.format, format.quality)\n const variantFilename = `${path.parse(safeFilename).name}-optimized.${format.format}`\n\n await fs.writeFile(path.join(staticDir, variantFilename), result.buffer)\n\n variants.push({\n format: format.format,\n filename: variantFilename,\n filesize: result.size,\n width: result.width,\n height: result.height,\n mimeType: result.mimeType,\n url: `/api/${input.collectionSlug}/file/${variantFilename}`,\n })\n }\n\n await req.payload.update({\n collection: input.collectionSlug as CollectionSlug,\n id: input.docId,\n data: {\n imageOptimizer: {\n ...doc.imageOptimizer,\n status: 'complete',\n variants,\n error: null,\n },\n },\n context: { imageOptimizer_skip: true },\n })\n\n return { output: { variantsGenerated: variants.length } }\n } catch (err) {\n const errorMessage = err instanceof Error ? err.message : String(err)\n\n try {\n await req.payload.update({\n collection: input.collectionSlug as CollectionSlug,\n id: input.docId,\n data: {\n imageOptimizer: {\n status: 'error',\n error: errorMessage,\n },\n },\n context: { imageOptimizer_skip: true },\n })\n } catch (updateErr) {\n req.payload.logger.error(\n { err: updateErr },\n 'Failed to persist error status for image optimizer',\n )\n }\n\n throw err\n }\n }\n}\n"],"names":["fs","path","resolveCollectionConfig","convertFormat","resolveStaticDir","fetchFileBuffer","isCloudStorage","createConvertFormatsHandler","resolvedConfig","input","req","doc","payload","findByID","collection","collectionSlug","id","docId","collectionConfig","collections","config","cloudStorage","update","data","imageOptimizer","status","variants","error","context","imageOptimizer_skip","output","variantsGenerated","staticDir","Error","fileBuffer","perCollectionConfig","formatsToGenerate","replaceOriginal","formats","length","slice","safeFilename","basename","filename","format","result","quality","variantFilename","parse","name","writeFile","join","buffer","push","filesize","size","width","height","mimeType","url","err","errorMessage","message","String","updateErr","logger"],"mappings":"AAAA,OAAOA,QAAQ,cAAa;AAC5B,OAAOC,UAAU,OAAM;AAKvB,SAASC,uBAAuB,QAAQ,iBAAgB;AACxD,SAASC,aAAa,QAAQ,yBAAwB;AACtD,SAASC,gBAAgB,QAAQ,mCAAkC;AACnE,SAASC,eAAe,EAAEC,cAAc,QAAQ,0BAAyB;AAEzE,OAAO,MAAMC,8BAA8B,CAACC;IAC1C,OAAO,OAAO,EAAEC,KAAK,EAAEC,GAAG,EAAkE;QAC1F,IAAI;YACF,MAAMC,MAAM,MAAMD,IAAIE,OAAO,CAACC,QAAQ,CAAC;gBACrCC,YAAYL,MAAMM,cAAc;gBAChCC,IAAIP,MAAMQ,KAAK;YACjB;YAEA,MAAMC,mBAAmBR,IAAIE,OAAO,CAACO,WAAW,CAACV,MAAMM,cAAc,CAAyC,CAACK,MAAM;YACrH,MAAMC,eAAef,eAAeY;YAEpC,iFAAiF;YACjF,6EAA6E;YAC7E,IAAIG,cAAc;gBAChB,MAAMX,IAAIE,OAAO,CAACU,MAAM,CAAC;oBACvBR,YAAYL,MAAMM,cAAc;oBAChCC,IAAIP,MAAMQ,KAAK;oBACfM,MAAM;wBACJC,gBAAgB;4BACd,GAAGb,IAAIa,cAAc;4BACrBC,QAAQ;4BACRC,UAAU,EAAE;4BACZC,OAAO;wBACT;oBACF;oBACAC,SAAS;wBAAEC,qBAAqB;oBAAK;gBACvC;gBACA,OAAO;oBAAEC,QAAQ;wBAAEC,mBAAmB;oBAAE;gBAAE;YAC5C;YAEA,MAAMC,YAAY5B,iBAAiBc;YACnC,IAAI,CAACc,WAAW;gBACd,MAAM,IAAIC,MAAM,CAAC,wCAAwC,EAAExB,MAAMM,cAAc,CAAC,CAAC,CAAC;YACpF;YAEA,MAAMmB,aAAa,MAAM7B,gBAAgBM,KAAKO;YAE9C,MAAMQ,WAQD,EAAE;YAEP,MAAMS,sBAAsBjC,wBAAwBM,gBAAgBC,MAAMM,cAAc;YAExF,+EAA+E;YAC/E,gEAAgE;YAChE,MAAMqB,oBAAoBD,oBAAoBE,eAAe,IAAIF,oBAAoBG,OAAO,CAACC,MAAM,GAAG,IAClGJ,oBAAoBG,OAAO,CAACE,KAAK,CAAC,KAClCL,oBAAoBG,OAAO;YAE/B,MAAMG,eAAexC,KAAKyC,QAAQ,CAAC/B,IAAIgC,QAAQ;YAE/C,KAAK,MAAMC,UAAUR,kBAAmB;gBACtC,MAAMS,SAAS,MAAM1C,cAAc+B,YAAYU,OAAOA,MAAM,EAAEA,OAAOE,OAAO;gBAC5E,MAAMC,kBAAkB,GAAG9C,KAAK+C,KAAK,CAACP,cAAcQ,IAAI,CAAC,WAAW,EAAEL,OAAOA,MAAM,EAAE;gBAErF,MAAM5C,GAAGkD,SAAS,CAACjD,KAAKkD,IAAI,CAACnB,WAAWe,kBAAkBF,OAAOO,MAAM;gBAEvE1B,SAAS2B,IAAI,CAAC;oBACZT,QAAQA,OAAOA,MAAM;oBACrBD,UAAUI;oBACVO,UAAUT,OAAOU,IAAI;oBACrBC,OAAOX,OAAOW,KAAK;oBACnBC,QAAQZ,OAAOY,MAAM;oBACrBC,UAAUb,OAAOa,QAAQ;oBACzBC,KAAK,CAAC,KAAK,EAAElD,MAAMM,cAAc,CAAC,MAAM,EAAEgC,iBAAiB;gBAC7D;YACF;YAEA,MAAMrC,IAAIE,OAAO,CAACU,MAAM,CAAC;gBACvBR,YAAYL,MAAMM,cAAc;gBAChCC,IAAIP,MAAMQ,KAAK;gBACfM,MAAM;oBACJC,gBAAgB;wBACd,GAAGb,IAAIa,cAAc;wBACrBC,QAAQ;wBACRC;wBACAC,OAAO;oBACT;gBACF;gBACAC,SAAS;oBAAEC,qBAAqB;gBAAK;YACvC;YAEA,OAAO;gBAAEC,QAAQ;oBAAEC,mBAAmBL,SAASa,MAAM;gBAAC;YAAE;QAC1D,EAAE,OAAOqB,KAAK;YACZ,MAAMC,eAAeD,eAAe3B,QAAQ2B,IAAIE,OAAO,GAAGC,OAAOH;YAEjE,IAAI;gBACF,MAAMlD,IAAIE,OAAO,CAACU,MAAM,CAAC;oBACvBR,YAAYL,MAAMM,cAAc;oBAChCC,IAAIP,MAAMQ,KAAK;oBACfM,MAAM;wBACJC,gBAAgB;4BACdC,QAAQ;4BACRE,OAAOkC;wBACT;oBACF;oBACAjC,SAAS;wBAAEC,qBAAqB;oBAAK;gBACvC;YACF,EAAE,OAAOmC,WAAW;gBAClBtD,IAAIE,OAAO,CAACqD,MAAM,CAACtC,KAAK,CACtB;oBAAEiC,KAAKI;gBAAU,GACjB;YAEJ;YAEA,MAAMJ;QACR;IACF;AACF,EAAC"}
1
+ {"version":3,"sources":["../../src/tasks/convertFormats.ts"],"sourcesContent":["import fs from 'fs/promises'\nimport path from 'path'\n\nimport type { CollectionSlug } from 'payload'\n\nimport type { ResolvedImageOptimizerConfig } from '../types.js'\nimport { resolveCollectionConfig } from '../defaults.js'\nimport { convertFormat } from '../processing/index.js'\nimport { resolveStaticDir } from '../utilities/resolveStaticDir.js'\nimport { fetchFileBuffer, isCloudStorage } from '../utilities/storage.js'\n\nexport const createConvertFormatsHandler = (resolvedConfig: ResolvedImageOptimizerConfig) => {\n return async ({ input, req }: { input: { collectionSlug: string; docId: string }; req: any }) => {\n try {\n const doc = await req.payload.findByID({\n collection: input.collectionSlug as CollectionSlug,\n id: input.docId,\n })\n\n const collectionConfig = req.payload.collections[input.collectionSlug as keyof typeof req.payload.collections].config\n const cloudStorage = isCloudStorage(collectionConfig)\n\n // Cloud storage: variant files cannot be uploaded without direct adapter access.\n // Mark as complete — CDN-level image optimization handles format conversion.\n if (cloudStorage) {\n await req.payload.update({\n collection: input.collectionSlug as CollectionSlug,\n id: input.docId,\n data: {\n imageOptimizer: {\n ...doc.imageOptimizer,\n status: 'complete',\n variants: [],\n error: null,\n },\n },\n context: { imageOptimizer_skip: true },\n })\n return { output: { variantsGenerated: 0 } }\n }\n\n const staticDir = resolveStaticDir(collectionConfig)\n if (!staticDir) {\n throw new Error(`No staticDir configured for collection \"${input.collectionSlug}\"`)\n }\n\n const fileBuffer = await fetchFileBuffer(doc, collectionConfig)\n\n const variants: Array<{\n filename: string\n filesize: number\n format: string\n height: number\n mimeType: string\n url: string\n width: number\n }> = []\n\n const perCollectionConfig = resolveCollectionConfig(resolvedConfig, input.collectionSlug)\n\n // When replaceOriginal is on, the main file is already in the primary format —\n // skip it and only generate variants for the remaining formats.\n const formatsToGenerate = perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0\n ? perCollectionConfig.formats.slice(1)\n : perCollectionConfig.formats\n\n const safeFilename = path.basename(doc.filename)\n\n for (const format of formatsToGenerate) {\n const result = await convertFormat(fileBuffer, format.format, format.quality)\n const variantFilename = `${path.parse(safeFilename).name}-optimized.${format.format}`\n\n await fs.writeFile(path.join(staticDir, variantFilename), result.buffer)\n\n variants.push({\n format: format.format,\n filename: variantFilename,\n filesize: result.size,\n width: result.width,\n height: result.height,\n mimeType: result.mimeType,\n url: `/api/${input.collectionSlug}/file/${variantFilename}`,\n })\n }\n\n await req.payload.update({\n collection: input.collectionSlug as CollectionSlug,\n id: input.docId,\n data: {\n imageOptimizer: {\n ...doc.imageOptimizer,\n status: 'complete',\n variants,\n error: null,\n },\n },\n context: { imageOptimizer_skip: true },\n })\n\n return { output: { variantsGenerated: variants.length } }\n } catch (err) {\n const errorMessage = err instanceof Error ? err.message : String(err)\n\n try {\n await req.payload.update({\n collection: input.collectionSlug as CollectionSlug,\n id: input.docId,\n data: {\n imageOptimizer: {\n status: 'error',\n error: errorMessage,\n },\n },\n context: { imageOptimizer_skip: true },\n })\n } catch (updateErr) {\n req.payload.logger.error(\n { err: updateErr, docId: input.docId, collectionSlug: input.collectionSlug },\n 'Failed to persist error status for image optimizer',\n )\n }\n\n throw err\n }\n }\n}\n"],"names":["fs","path","resolveCollectionConfig","convertFormat","resolveStaticDir","fetchFileBuffer","isCloudStorage","createConvertFormatsHandler","resolvedConfig","input","req","doc","payload","findByID","collection","collectionSlug","id","docId","collectionConfig","collections","config","cloudStorage","update","data","imageOptimizer","status","variants","error","context","imageOptimizer_skip","output","variantsGenerated","staticDir","Error","fileBuffer","perCollectionConfig","formatsToGenerate","replaceOriginal","formats","length","slice","safeFilename","basename","filename","format","result","quality","variantFilename","parse","name","writeFile","join","buffer","push","filesize","size","width","height","mimeType","url","err","errorMessage","message","String","updateErr","logger"],"mappings":"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"}
@@ -0,0 +1,18 @@
1
+ import type { ResolvedImageOptimizerConfig } from '../types.js';
2
+ export declare const createRegenerateDocumentHandler: (resolvedConfig: ResolvedImageOptimizerConfig) => ({ input, req }: {
3
+ input: {
4
+ collectionSlug: string;
5
+ docId: string;
6
+ };
7
+ req: any;
8
+ }) => Promise<{
9
+ output: {
10
+ status: string;
11
+ reason: string;
12
+ };
13
+ } | {
14
+ output: {
15
+ status: string;
16
+ reason?: undefined;
17
+ };
18
+ }>;
@@ -155,7 +155,9 @@ export const createRegenerateDocumentHandler = (resolvedConfig)=>{
155
155
  });
156
156
  } catch (updateErr) {
157
157
  req.payload.logger.error({
158
- err: updateErr
158
+ err: updateErr,
159
+ docId: input.docId,
160
+ collectionSlug: input.collectionSlug
159
161
  }, 'Failed to persist error status for image optimizer regeneration');
160
162
  }
161
163
  throw err;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/tasks/regenerateDocument.ts"],"sourcesContent":["import fs from 'fs/promises'\nimport path from 'path'\n\nimport type { CollectionSlug } from 'payload'\n\nimport type { ResolvedImageOptimizerConfig } from '../types.js'\nimport { resolveCollectionConfig } from '../defaults.js'\nimport { stripAndResize, generateThumbHash, convertFormat } from '../processing/index.js'\nimport { resolveStaticDir } from '../utilities/resolveStaticDir.js'\nimport { fetchFileBuffer, isCloudStorage } from '../utilities/storage.js'\n\nexport const createRegenerateDocumentHandler = (resolvedConfig: ResolvedImageOptimizerConfig) => {\n return async ({ input, req }: { input: { collectionSlug: string; docId: string }; req: any }) => {\n try {\n const doc = await req.payload.findByID({\n collection: input.collectionSlug as CollectionSlug,\n id: input.docId,\n })\n\n // Skip non-image documents\n if (!doc.mimeType || !doc.mimeType.startsWith('image/')) {\n return { output: { status: 'skipped', reason: 'not-image' } }\n }\n\n const collectionConfig = req.payload.collections[input.collectionSlug as keyof typeof req.payload.collections].config\n const cloudStorage = isCloudStorage(collectionConfig)\n\n const fileBuffer = await fetchFileBuffer(doc, collectionConfig)\n const originalSize = fileBuffer.length\n const perCollectionConfig = resolveCollectionConfig(resolvedConfig, input.collectionSlug)\n\n // Sanitize filename to prevent path traversal\n const safeFilename = path.basename(doc.filename)\n\n // Step 1: Strip metadata + resize\n const processed = await stripAndResize(\n fileBuffer,\n perCollectionConfig.maxDimensions,\n resolvedConfig.stripMetadata,\n )\n\n let mainBuffer = processed.buffer\n let mainSize = processed.size\n let newFilename = safeFilename\n let newMimeType: string | undefined\n\n // Step 1b: If replaceOriginal, convert main file to primary format\n if (perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0) {\n const primaryFormat = perCollectionConfig.formats[0]\n const converted = await convertFormat(processed.buffer, primaryFormat.format, primaryFormat.quality)\n mainBuffer = converted.buffer\n mainSize = converted.size\n newFilename = `${path.parse(safeFilename).name}.${primaryFormat.format}`\n newMimeType = converted.mimeType\n }\n\n // Step 2: Generate ThumbHash\n let thumbHash: string | undefined\n if (resolvedConfig.generateThumbHash) {\n thumbHash = await generateThumbHash(mainBuffer)\n }\n\n // Step 3: Store the optimized file\n const variants: Array<{\n filename: string\n filesize: number\n format: string\n height: number\n mimeType: string\n url: string\n width: number\n }> = []\n\n if (cloudStorage) {\n // Cloud storage: re-upload the optimized file via Payload's update API.\n // This triggers the cloud adapter's afterChange hook which uploads to cloud.\n const updateData: Record<string, any> = {\n imageOptimizer: {\n originalSize,\n optimizedSize: mainSize,\n status: 'complete',\n thumbHash,\n variants: [],\n error: null,\n },\n }\n\n if (newFilename !== safeFilename) {\n updateData.filename = newFilename\n updateData.filesize = mainSize\n updateData.mimeType = newMimeType\n }\n\n await req.payload.update({\n collection: input.collectionSlug as CollectionSlug,\n id: input.docId,\n data: updateData,\n file: {\n data: mainBuffer,\n mimetype: newMimeType || doc.mimeType,\n name: newFilename,\n size: mainSize,\n },\n context: { imageOptimizer_skip: true },\n })\n } else {\n // Local storage: write files to disk\n const staticDir = resolveStaticDir(collectionConfig)\n const newFilePath = path.join(staticDir, newFilename)\n await fs.writeFile(newFilePath, mainBuffer)\n\n // Clean up old file if filename changed\n if (newFilename !== safeFilename) {\n const oldFilePath = path.join(staticDir, safeFilename)\n await fs.unlink(oldFilePath).catch(() => {})\n }\n\n // Generate variant files (local storage only)\n const formatsToGenerate = perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0\n ? perCollectionConfig.formats.slice(1)\n : perCollectionConfig.formats\n\n for (const format of formatsToGenerate) {\n const result = await convertFormat(mainBuffer, format.format, format.quality)\n const variantFilename = `${path.parse(newFilename).name}-optimized.${format.format}`\n await fs.writeFile(path.join(staticDir, variantFilename), result.buffer)\n\n variants.push({\n format: format.format,\n filename: variantFilename,\n filesize: result.size,\n width: result.width,\n height: result.height,\n mimeType: result.mimeType,\n url: `/api/${input.collectionSlug}/file/${variantFilename}`,\n })\n }\n\n // Update the document with optimization data\n const updateData: Record<string, any> = {\n imageOptimizer: {\n originalSize,\n optimizedSize: mainSize,\n status: 'complete',\n thumbHash,\n variants,\n error: null,\n },\n }\n\n if (newFilename !== safeFilename) {\n updateData.filename = newFilename\n updateData.filesize = mainSize\n updateData.mimeType = newMimeType\n }\n\n await req.payload.update({\n collection: input.collectionSlug as CollectionSlug,\n id: input.docId,\n data: updateData,\n context: { imageOptimizer_skip: true },\n })\n }\n\n return { output: { status: 'complete' } }\n } catch (err) {\n const errorMessage = err instanceof Error ? err.message : String(err)\n\n try {\n await req.payload.update({\n collection: input.collectionSlug as CollectionSlug,\n id: input.docId,\n data: {\n imageOptimizer: {\n status: 'error',\n error: errorMessage,\n },\n },\n context: { imageOptimizer_skip: true },\n })\n } catch (updateErr) {\n req.payload.logger.error(\n { err: updateErr },\n 'Failed to persist error status for image optimizer regeneration',\n )\n }\n\n throw err\n }\n }\n}\n"],"names":["fs","path","resolveCollectionConfig","stripAndResize","generateThumbHash","convertFormat","resolveStaticDir","fetchFileBuffer","isCloudStorage","createRegenerateDocumentHandler","resolvedConfig","input","req","doc","payload","findByID","collection","collectionSlug","id","docId","mimeType","startsWith","output","status","reason","collectionConfig","collections","config","cloudStorage","fileBuffer","originalSize","length","perCollectionConfig","safeFilename","basename","filename","processed","maxDimensions","stripMetadata","mainBuffer","buffer","mainSize","size","newFilename","newMimeType","replaceOriginal","formats","primaryFormat","converted","format","quality","parse","name","thumbHash","variants","updateData","imageOptimizer","optimizedSize","error","filesize","update","data","file","mimetype","context","imageOptimizer_skip","staticDir","newFilePath","join","writeFile","oldFilePath","unlink","catch","formatsToGenerate","slice","result","variantFilename","push","width","height","url","err","errorMessage","Error","message","String","updateErr","logger"],"mappings":"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"}
1
+ {"version":3,"sources":["../../src/tasks/regenerateDocument.ts"],"sourcesContent":["import fs from 'fs/promises'\nimport path from 'path'\n\nimport type { CollectionSlug } from 'payload'\n\nimport type { ResolvedImageOptimizerConfig } from '../types.js'\nimport { resolveCollectionConfig } from '../defaults.js'\nimport { stripAndResize, generateThumbHash, convertFormat } from '../processing/index.js'\nimport { resolveStaticDir } from '../utilities/resolveStaticDir.js'\nimport { fetchFileBuffer, isCloudStorage } from '../utilities/storage.js'\n\nexport const createRegenerateDocumentHandler = (resolvedConfig: ResolvedImageOptimizerConfig) => {\n return async ({ input, req }: { input: { collectionSlug: string; docId: string }; req: any }) => {\n try {\n const doc = await req.payload.findByID({\n collection: input.collectionSlug as CollectionSlug,\n id: input.docId,\n })\n\n // Skip non-image documents\n if (!doc.mimeType || !doc.mimeType.startsWith('image/')) {\n return { output: { status: 'skipped', reason: 'not-image' } }\n }\n\n const collectionConfig = req.payload.collections[input.collectionSlug as keyof typeof req.payload.collections].config\n const cloudStorage = isCloudStorage(collectionConfig)\n\n const fileBuffer = await fetchFileBuffer(doc, collectionConfig)\n const originalSize = fileBuffer.length\n const perCollectionConfig = resolveCollectionConfig(resolvedConfig, input.collectionSlug)\n\n // Sanitize filename to prevent path traversal\n const safeFilename = path.basename(doc.filename)\n\n // Step 1: Strip metadata + resize\n const processed = await stripAndResize(\n fileBuffer,\n perCollectionConfig.maxDimensions,\n resolvedConfig.stripMetadata,\n )\n\n let mainBuffer = processed.buffer\n let mainSize = processed.size\n let newFilename = safeFilename\n let newMimeType: string | undefined\n\n // Step 1b: If replaceOriginal, convert main file to primary format\n if (perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0) {\n const primaryFormat = perCollectionConfig.formats[0]\n const converted = await convertFormat(processed.buffer, primaryFormat.format, primaryFormat.quality)\n mainBuffer = converted.buffer\n mainSize = converted.size\n newFilename = `${path.parse(safeFilename).name}.${primaryFormat.format}`\n newMimeType = converted.mimeType\n }\n\n // Step 2: Generate ThumbHash\n let thumbHash: string | undefined\n if (resolvedConfig.generateThumbHash) {\n thumbHash = await generateThumbHash(mainBuffer)\n }\n\n // Step 3: Store the optimized file\n const variants: Array<{\n filename: string\n filesize: number\n format: string\n height: number\n mimeType: string\n url: string\n width: number\n }> = []\n\n if (cloudStorage) {\n // Cloud storage: re-upload the optimized file via Payload's update API.\n // This triggers the cloud adapter's afterChange hook which uploads to cloud.\n const updateData: Record<string, any> = {\n imageOptimizer: {\n originalSize,\n optimizedSize: mainSize,\n status: 'complete',\n thumbHash,\n variants: [],\n error: null,\n },\n }\n\n if (newFilename !== safeFilename) {\n updateData.filename = newFilename\n updateData.filesize = mainSize\n updateData.mimeType = newMimeType\n }\n\n await req.payload.update({\n collection: input.collectionSlug as CollectionSlug,\n id: input.docId,\n data: updateData,\n file: {\n data: mainBuffer,\n mimetype: newMimeType || doc.mimeType,\n name: newFilename,\n size: mainSize,\n },\n context: { imageOptimizer_skip: true },\n })\n } else {\n // Local storage: write files to disk\n const staticDir = resolveStaticDir(collectionConfig)\n const newFilePath = path.join(staticDir, newFilename)\n await fs.writeFile(newFilePath, mainBuffer)\n\n // Clean up old file if filename changed\n if (newFilename !== safeFilename) {\n const oldFilePath = path.join(staticDir, safeFilename)\n await fs.unlink(oldFilePath).catch(() => {})\n }\n\n // Generate variant files (local storage only)\n const formatsToGenerate = perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0\n ? perCollectionConfig.formats.slice(1)\n : perCollectionConfig.formats\n\n for (const format of formatsToGenerate) {\n const result = await convertFormat(mainBuffer, format.format, format.quality)\n const variantFilename = `${path.parse(newFilename).name}-optimized.${format.format}`\n await fs.writeFile(path.join(staticDir, variantFilename), result.buffer)\n\n variants.push({\n format: format.format,\n filename: variantFilename,\n filesize: result.size,\n width: result.width,\n height: result.height,\n mimeType: result.mimeType,\n url: `/api/${input.collectionSlug}/file/${variantFilename}`,\n })\n }\n\n // Update the document with optimization data\n const updateData: Record<string, any> = {\n imageOptimizer: {\n originalSize,\n optimizedSize: mainSize,\n status: 'complete',\n thumbHash,\n variants,\n error: null,\n },\n }\n\n if (newFilename !== safeFilename) {\n updateData.filename = newFilename\n updateData.filesize = mainSize\n updateData.mimeType = newMimeType\n }\n\n await req.payload.update({\n collection: input.collectionSlug as CollectionSlug,\n id: input.docId,\n data: updateData,\n context: { imageOptimizer_skip: true },\n })\n }\n\n return { output: { status: 'complete' } }\n } catch (err) {\n const errorMessage = err instanceof Error ? err.message : String(err)\n\n try {\n await req.payload.update({\n collection: input.collectionSlug as CollectionSlug,\n id: input.docId,\n data: {\n imageOptimizer: {\n status: 'error',\n error: errorMessage,\n },\n },\n context: { imageOptimizer_skip: true },\n })\n } catch (updateErr) {\n req.payload.logger.error(\n { err: updateErr, docId: input.docId, collectionSlug: input.collectionSlug },\n 'Failed to persist error status for image optimizer regeneration',\n )\n }\n\n throw err\n }\n }\n}\n"],"names":["fs","path","resolveCollectionConfig","stripAndResize","generateThumbHash","convertFormat","resolveStaticDir","fetchFileBuffer","isCloudStorage","createRegenerateDocumentHandler","resolvedConfig","input","req","doc","payload","findByID","collection","collectionSlug","id","docId","mimeType","startsWith","output","status","reason","collectionConfig","collections","config","cloudStorage","fileBuffer","originalSize","length","perCollectionConfig","safeFilename","basename","filename","processed","maxDimensions","stripMetadata","mainBuffer","buffer","mainSize","size","newFilename","newMimeType","replaceOriginal","formats","primaryFormat","converted","format","quality","parse","name","thumbHash","variants","updateData","imageOptimizer","optimizedSize","error","filesize","update","data","file","mimetype","context","imageOptimizer_skip","staticDir","newFilePath","join","writeFile","oldFilePath","unlink","catch","formatsToGenerate","slice","result","variantFilename","push","width","height","url","err","errorMessage","Error","message","String","updateErr","logger"],"mappings":"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"}
@@ -0,0 +1 @@
1
+ export declare const translations: Record<string, Record<string, Record<string, string>>>;
@@ -0,0 +1,57 @@
1
+ import type { CollectionSlug, Field } from 'payload';
2
+ export type ImageFormat = 'webp' | 'avif';
3
+ export type FormatQuality = {
4
+ format: ImageFormat;
5
+ quality: number;
6
+ };
7
+ export type CollectionOptimizerConfig = {
8
+ formats?: FormatQuality[];
9
+ maxDimensions?: {
10
+ width: number;
11
+ height: number;
12
+ };
13
+ replaceOriginal?: boolean;
14
+ };
15
+ export type FieldsOverride = (args: {
16
+ defaultFields: Field[];
17
+ }) => Field[];
18
+ export type ImageOptimizerConfig = {
19
+ collections: Partial<Record<CollectionSlug, true | CollectionOptimizerConfig>>;
20
+ disabled?: boolean;
21
+ fieldsOverride?: FieldsOverride;
22
+ formats?: FormatQuality[];
23
+ generateThumbHash?: boolean;
24
+ maxDimensions?: {
25
+ width: number;
26
+ height: number;
27
+ };
28
+ replaceOriginal?: boolean;
29
+ stripMetadata?: boolean;
30
+ };
31
+ export type ResolvedCollectionOptimizerConfig = {
32
+ formats: FormatQuality[];
33
+ maxDimensions: {
34
+ width: number;
35
+ height: number;
36
+ };
37
+ replaceOriginal: boolean;
38
+ };
39
+ export type ResolvedImageOptimizerConfig = Required<Pick<ImageOptimizerConfig, 'formats' | 'generateThumbHash' | 'maxDimensions' | 'stripMetadata'>> & {
40
+ collections: ImageOptimizerConfig['collections'];
41
+ disabled: boolean;
42
+ replaceOriginal: boolean;
43
+ };
44
+ export type ImageOptimizerData = {
45
+ thumbHash?: string | null;
46
+ };
47
+ export type MediaResource = {
48
+ url?: string | null;
49
+ alt?: string | null;
50
+ width?: number | null;
51
+ height?: number | null;
52
+ filename?: string | null;
53
+ focalX?: number | null;
54
+ focalY?: number | null;
55
+ imageOptimizer?: ImageOptimizerData | null;
56
+ updatedAt?: string;
57
+ };
@@ -0,0 +1,24 @@
1
+ import type { MediaResource } from '../types.js';
2
+ export type ImageOptimizerProps = {
3
+ placeholder: 'blur' | 'empty';
4
+ blurDataURL?: string;
5
+ style: {
6
+ objectPosition: string;
7
+ };
8
+ };
9
+ /**
10
+ * Extracts image optimization props from a Payload media resource.
11
+ *
12
+ * Returns props that can be spread onto a Next.js `<Image>` component to add
13
+ * ThumbHash blur placeholders and focal-point-based object positioning.
14
+ *
15
+ * Works with any component — including the Payload website template's `ImageMedia`:
16
+ *
17
+ * ```tsx
18
+ * import { getImageOptimizerProps } from '@inoo-ch/payload-image-optimizer/client'
19
+ *
20
+ * const optimizerProps = getImageOptimizerProps(resource)
21
+ * <NextImage {...existingProps} {...optimizerProps} />
22
+ * ```
23
+ */
24
+ export declare function getImageOptimizerProps(resource: MediaResource | null | undefined): ImageOptimizerProps;
@@ -0,0 +1,3 @@
1
+ export declare function resolveStaticDir(collectionConfig: {
2
+ upload?: boolean | Record<string, any>;
3
+ }): string;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Returns true when the collection uses cloud/external storage (disableLocalStorage: true).
3
+ * When true, files are uploaded by external adapter hooks — no local FS writes should happen.
4
+ */
5
+ export declare function isCloudStorage(collectionConfig: {
6
+ upload?: boolean | Record<string, any>;
7
+ }): boolean;
8
+ /**
9
+ * Reads a file buffer from local disk or fetches it from URL.
10
+ * Tries local disk first (when available), falls back to URL fetch.
11
+ * This makes the plugin storage-agnostic — works with local FS and cloud storage alike.
12
+ */
13
+ export declare function fetchFileBuffer(doc: {
14
+ filename?: string;
15
+ url?: string;
16
+ }, collectionConfig: {
17
+ upload?: boolean | Record<string, any>;
18
+ }): Promise<Buffer>;
@@ -27,7 +27,12 @@ import { resolveStaticDir } from './resolveStaticDir.js';
27
27
  // Fetch from URL (works for cloud storage and as fallback for local)
28
28
  if (doc.url) {
29
29
  const url = doc.url.startsWith('http') ? doc.url : `${process.env.NEXT_PUBLIC_SERVER_URL || ''}${doc.url}`;
30
- const response = await fetch(url);
30
+ if (!url.startsWith('http')) {
31
+ throw new Error(`Cannot fetch file "${doc.filename}": URL "${doc.url}" is relative and NEXT_PUBLIC_SERVER_URL is not set`);
32
+ }
33
+ const response = await fetch(url, {
34
+ signal: AbortSignal.timeout(30_000)
35
+ });
31
36
  if (!response.ok) {
32
37
  throw new Error(`Failed to fetch file from ${url}: ${response.status} ${response.statusText}`);
33
38
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utilities/storage.ts"],"sourcesContent":["import fs from 'fs/promises'\nimport path from 'path'\n\nimport { resolveStaticDir } from './resolveStaticDir.js'\n\n/**\n * Returns true when the collection uses cloud/external storage (disableLocalStorage: true).\n * When true, files are uploaded by external adapter hooks — no local FS writes should happen.\n */\nexport function isCloudStorage(collectionConfig: { upload?: boolean | Record<string, any> }): boolean {\n return typeof collectionConfig.upload === 'object' && collectionConfig.upload.disableLocalStorage === true\n}\n\n/**\n * Reads a file buffer from local disk or fetches it from URL.\n * Tries local disk first (when available), falls back to URL fetch.\n * This makes the plugin storage-agnostic — works with local FS and cloud storage alike.\n */\nexport async function fetchFileBuffer(\n doc: { filename?: string; url?: string },\n collectionConfig: { upload?: boolean | Record<string, any> },\n): Promise<Buffer> {\n const safeFilename = doc.filename ? path.basename(doc.filename) : undefined\n\n // Try local disk first (only when local storage is enabled)\n if (!isCloudStorage(collectionConfig) && safeFilename) {\n const staticDir = resolveStaticDir(collectionConfig)\n if (staticDir) {\n try {\n return await fs.readFile(path.join(staticDir, safeFilename))\n } catch {\n // Fall through to URL fetch\n }\n }\n }\n\n // Fetch from URL (works for cloud storage and as fallback for local)\n if (doc.url) {\n const url = doc.url.startsWith('http')\n ? doc.url\n : `${process.env.NEXT_PUBLIC_SERVER_URL || ''}${doc.url}`\n const response = await fetch(url)\n if (!response.ok) {\n throw new Error(`Failed to fetch file from ${url}: ${response.status} ${response.statusText}`)\n }\n return Buffer.from(await response.arrayBuffer())\n }\n\n throw new Error(`Cannot read file: no local path or URL available for \"${doc.filename}\"`)\n}\n"],"names":["fs","path","resolveStaticDir","isCloudStorage","collectionConfig","upload","disableLocalStorage","fetchFileBuffer","doc","safeFilename","filename","basename","undefined","staticDir","readFile","join","url","startsWith","process","env","NEXT_PUBLIC_SERVER_URL","response","fetch","ok","Error","status","statusText","Buffer","from","arrayBuffer"],"mappings":"AAAA,OAAOA,QAAQ,cAAa;AAC5B,OAAOC,UAAU,OAAM;AAEvB,SAASC,gBAAgB,QAAQ,wBAAuB;AAExD;;;CAGC,GACD,OAAO,SAASC,eAAeC,gBAA4D;IACzF,OAAO,OAAOA,iBAAiBC,MAAM,KAAK,YAAYD,iBAAiBC,MAAM,CAACC,mBAAmB,KAAK;AACxG;AAEA;;;;CAIC,GACD,OAAO,eAAeC,gBACpBC,GAAwC,EACxCJ,gBAA4D;IAE5D,MAAMK,eAAeD,IAAIE,QAAQ,GAAGT,KAAKU,QAAQ,CAACH,IAAIE,QAAQ,IAAIE;IAElE,4DAA4D;IAC5D,IAAI,CAACT,eAAeC,qBAAqBK,cAAc;QACrD,MAAMI,YAAYX,iBAAiBE;QACnC,IAAIS,WAAW;YACb,IAAI;gBACF,OAAO,MAAMb,GAAGc,QAAQ,CAACb,KAAKc,IAAI,CAACF,WAAWJ;YAChD,EAAE,OAAM;YACN,4BAA4B;YAC9B;QACF;IACF;IAEA,qEAAqE;IACrE,IAAID,IAAIQ,GAAG,EAAE;QACX,MAAMA,MAAMR,IAAIQ,GAAG,CAACC,UAAU,CAAC,UAC3BT,IAAIQ,GAAG,GACP,GAAGE,QAAQC,GAAG,CAACC,sBAAsB,IAAI,KAAKZ,IAAIQ,GAAG,EAAE;QAC3D,MAAMK,WAAW,MAAMC,MAAMN;QAC7B,IAAI,CAACK,SAASE,EAAE,EAAE;YAChB,MAAM,IAAIC,MAAM,CAAC,0BAA0B,EAAER,IAAI,EAAE,EAAEK,SAASI,MAAM,CAAC,CAAC,EAAEJ,SAASK,UAAU,EAAE;QAC/F;QACA,OAAOC,OAAOC,IAAI,CAAC,MAAMP,SAASQ,WAAW;IAC/C;IAEA,MAAM,IAAIL,MAAM,CAAC,sDAAsD,EAAEhB,IAAIE,QAAQ,CAAC,CAAC,CAAC;AAC1F"}
1
+ {"version":3,"sources":["../../src/utilities/storage.ts"],"sourcesContent":["import fs from 'fs/promises'\nimport path from 'path'\n\nimport { resolveStaticDir } from './resolveStaticDir.js'\n\n/**\n * Returns true when the collection uses cloud/external storage (disableLocalStorage: true).\n * When true, files are uploaded by external adapter hooks — no local FS writes should happen.\n */\nexport function isCloudStorage(collectionConfig: { upload?: boolean | Record<string, any> }): boolean {\n return typeof collectionConfig.upload === 'object' && collectionConfig.upload.disableLocalStorage === true\n}\n\n/**\n * Reads a file buffer from local disk or fetches it from URL.\n * Tries local disk first (when available), falls back to URL fetch.\n * This makes the plugin storage-agnostic — works with local FS and cloud storage alike.\n */\nexport async function fetchFileBuffer(\n doc: { filename?: string; url?: string },\n collectionConfig: { upload?: boolean | Record<string, any> },\n): Promise<Buffer> {\n const safeFilename = doc.filename ? path.basename(doc.filename) : undefined\n\n // Try local disk first (only when local storage is enabled)\n if (!isCloudStorage(collectionConfig) && safeFilename) {\n const staticDir = resolveStaticDir(collectionConfig)\n if (staticDir) {\n try {\n return await fs.readFile(path.join(staticDir, safeFilename))\n } catch {\n // Fall through to URL fetch\n }\n }\n }\n\n // Fetch from URL (works for cloud storage and as fallback for local)\n if (doc.url) {\n const url = doc.url.startsWith('http')\n ? doc.url\n : `${process.env.NEXT_PUBLIC_SERVER_URL || ''}${doc.url}`\n\n if (!url.startsWith('http')) {\n throw new Error(\n `Cannot fetch file \"${doc.filename}\": URL \"${doc.url}\" is relative and NEXT_PUBLIC_SERVER_URL is not set`,\n )\n }\n\n const response = await fetch(url, { signal: AbortSignal.timeout(30_000) })\n if (!response.ok) {\n throw new Error(`Failed to fetch file from ${url}: ${response.status} ${response.statusText}`)\n }\n return Buffer.from(await response.arrayBuffer())\n }\n\n throw new Error(`Cannot read file: no local path or URL available for \"${doc.filename}\"`)\n}\n"],"names":["fs","path","resolveStaticDir","isCloudStorage","collectionConfig","upload","disableLocalStorage","fetchFileBuffer","doc","safeFilename","filename","basename","undefined","staticDir","readFile","join","url","startsWith","process","env","NEXT_PUBLIC_SERVER_URL","Error","response","fetch","signal","AbortSignal","timeout","ok","status","statusText","Buffer","from","arrayBuffer"],"mappings":"AAAA,OAAOA,QAAQ,cAAa;AAC5B,OAAOC,UAAU,OAAM;AAEvB,SAASC,gBAAgB,QAAQ,wBAAuB;AAExD;;;CAGC,GACD,OAAO,SAASC,eAAeC,gBAA4D;IACzF,OAAO,OAAOA,iBAAiBC,MAAM,KAAK,YAAYD,iBAAiBC,MAAM,CAACC,mBAAmB,KAAK;AACxG;AAEA;;;;CAIC,GACD,OAAO,eAAeC,gBACpBC,GAAwC,EACxCJ,gBAA4D;IAE5D,MAAMK,eAAeD,IAAIE,QAAQ,GAAGT,KAAKU,QAAQ,CAACH,IAAIE,QAAQ,IAAIE;IAElE,4DAA4D;IAC5D,IAAI,CAACT,eAAeC,qBAAqBK,cAAc;QACrD,MAAMI,YAAYX,iBAAiBE;QACnC,IAAIS,WAAW;YACb,IAAI;gBACF,OAAO,MAAMb,GAAGc,QAAQ,CAACb,KAAKc,IAAI,CAACF,WAAWJ;YAChD,EAAE,OAAM;YACN,4BAA4B;YAC9B;QACF;IACF;IAEA,qEAAqE;IACrE,IAAID,IAAIQ,GAAG,EAAE;QACX,MAAMA,MAAMR,IAAIQ,GAAG,CAACC,UAAU,CAAC,UAC3BT,IAAIQ,GAAG,GACP,GAAGE,QAAQC,GAAG,CAACC,sBAAsB,IAAI,KAAKZ,IAAIQ,GAAG,EAAE;QAE3D,IAAI,CAACA,IAAIC,UAAU,CAAC,SAAS;YAC3B,MAAM,IAAII,MACR,CAAC,mBAAmB,EAAEb,IAAIE,QAAQ,CAAC,QAAQ,EAAEF,IAAIQ,GAAG,CAAC,mDAAmD,CAAC;QAE7G;QAEA,MAAMM,WAAW,MAAMC,MAAMP,KAAK;YAAEQ,QAAQC,YAAYC,OAAO,CAAC;QAAQ;QACxE,IAAI,CAACJ,SAASK,EAAE,EAAE;YAChB,MAAM,IAAIN,MAAM,CAAC,0BAA0B,EAAEL,IAAI,EAAE,EAAEM,SAASM,MAAM,CAAC,CAAC,EAAEN,SAASO,UAAU,EAAE;QAC/F;QACA,OAAOC,OAAOC,IAAI,CAAC,MAAMT,SAASU,WAAW;IAC/C;IAEA,MAAM,IAAIX,MAAM,CAAC,sDAAsD,EAAEb,IAAIE,QAAQ,CAAC,CAAC,CAAC;AAC1F"}
@@ -0,0 +1,2 @@
1
+ export declare function encodeImageToThumbHash(buffer: Buffer, width: number, height: number): string;
2
+ export declare function decodeThumbHashToDataURL(thumbHash: string): string;
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Extends the serverless function lifetime to keep a promise alive after the
3
+ * response is sent. Uses the Next.js `waitUntil` context when available
4
+ * (Vercel / serverless). In non-serverless environments, the promise runs
5
+ * fire-and-forget as before — Node.js keeps the process alive regardless.
6
+ */
7
+ export declare function waitUntil(promise: Promise<unknown>): void;
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Extends the serverless function lifetime to keep a promise alive after the
3
+ * response is sent. Uses the Next.js `waitUntil` context when available
4
+ * (Vercel / serverless). In non-serverless environments, the promise runs
5
+ * fire-and-forget as before — Node.js keeps the process alive regardless.
6
+ */ export function waitUntil(promise) {
7
+ const ctx = globalThis.__next_request_context;
8
+ ctx?.waitUntil?.(promise);
9
+ }
10
+
11
+ //# sourceMappingURL=waitUntil.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/utilities/waitUntil.ts"],"sourcesContent":["/**\n * Extends the serverless function lifetime to keep a promise alive after the\n * response is sent. Uses the Next.js `waitUntil` context when available\n * (Vercel / serverless). In non-serverless environments, the promise runs\n * fire-and-forget as before — Node.js keeps the process alive regardless.\n */\nexport function waitUntil(promise: Promise<unknown>): void {\n const ctx = (globalThis as Record<string, unknown>).__next_request_context as\n | { waitUntil?: (p: Promise<unknown>) => void }\n | undefined\n\n ctx?.waitUntil?.(promise)\n}\n"],"names":["waitUntil","promise","ctx","globalThis","__next_request_context"],"mappings":"AAAA;;;;;CAKC,GACD,OAAO,SAASA,UAAUC,OAAyB;IACjD,MAAMC,MAAM,AAACC,WAAuCC,sBAAsB;IAI1EF,KAAKF,YAAYC;AACnB"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inoo-ch/payload-image-optimizer",
3
- "version": "1.3.8",
3
+ "version": "1.4.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,6 +2,7 @@ import type { PayloadHandler } from 'payload'
2
2
  import type { CollectionSlug, Where } from 'payload'
3
3
 
4
4
  import type { ResolvedImageOptimizerConfig } from '../types.js'
5
+ import { waitUntil } from '../utilities/waitUntil.js'
5
6
 
6
7
  export const createRegenerateHandler = (resolvedConfig: ResolvedImageOptimizerConfig) => {
7
8
  const handler: PayloadHandler = async (req) => {
@@ -71,11 +72,13 @@ export const createRegenerateHandler = (resolvedConfig: ResolvedImageOptimizerCo
71
72
 
72
73
  req.payload.logger.info(`Image optimizer: queued ${queued} images from '${collectionSlug}' for regeneration`)
73
74
 
74
- // Fire the job runner (non-blocking)
75
+ // Fire the job runner — use waitUntil to keep the serverless function alive
76
+ // after the response is sent, so jobs actually complete on Vercel/serverless.
75
77
  if (queued > 0) {
76
- req.payload.jobs.run({ limit: queued }).catch((err: unknown) => {
78
+ const runPromise = req.payload.jobs.run({ limit: queued }).catch((err: unknown) => {
77
79
  req.payload.logger.error({ err }, 'Regeneration job runner failed')
78
80
  })
81
+ waitUntil(runPromise)
79
82
  }
80
83
 
81
84
  return Response.json({ queued, collectionSlug })
@@ -1,11 +1,11 @@
1
1
  import fs from 'fs/promises'
2
2
  import path from 'path'
3
- import type { CollectionAfterChangeHook, CollectionSlug } from 'payload'
3
+ import type { CollectionAfterChangeHook } from 'payload'
4
4
 
5
5
  import type { ResolvedImageOptimizerConfig } from '../types.js'
6
- import { resolveCollectionConfig } from '../defaults.js'
7
6
  import { resolveStaticDir } from '../utilities/resolveStaticDir.js'
8
7
  import { isCloudStorage } from '../utilities/storage.js'
8
+ import { waitUntil } from '../utilities/waitUntil.js'
9
9
 
10
10
  export const createAfterChangeHook = (
11
11
  resolvedConfig: ResolvedImageOptimizerConfig,
@@ -45,44 +45,11 @@ export const createAfterChangeHook = (
45
45
  }
46
46
  }
47
47
 
48
- const perCollectionConfig = resolveCollectionConfig(resolvedConfig, collectionSlug)
49
-
50
- // When replaceOriginal is on and only one format is configured, the main file
51
- // is already converted skip the async job and mark complete immediately.
52
- if (perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length <= 1) {
53
- await req.payload.update({
54
- collection: collectionSlug as CollectionSlug,
55
- id: doc.id,
56
- data: {
57
- imageOptimizer: {
58
- ...doc.imageOptimizer,
59
- status: 'complete',
60
- variants: [],
61
- error: null,
62
- },
63
- },
64
- context: { imageOptimizer_skip: true },
65
- })
66
- return doc
67
- }
68
-
69
- // With cloud storage, variant files cannot be written — skip the async job
70
- // and mark complete. CDN-level image optimization (e.g. Next.js Image) can
71
- // serve alternative formats on the fly.
72
- if (cloudStorage) {
73
- await req.payload.update({
74
- collection: collectionSlug as CollectionSlug,
75
- id: doc.id,
76
- data: {
77
- imageOptimizer: {
78
- ...doc.imageOptimizer,
79
- status: 'complete',
80
- variants: [],
81
- error: null,
82
- },
83
- },
84
- context: { imageOptimizer_skip: true },
85
- })
48
+ // When status was already resolved in beforeChange (cloud storage, or
49
+ // replaceOriginal with a single format), no async job or update is needed.
50
+ // This avoids a separate update() call that fails with 404 on MongoDB due to
51
+ // transaction isolation when cloud storage adapters are involved.
52
+ if (context?.imageOptimizer_statusResolved) {
86
53
  return doc
87
54
  }
88
55
 
@@ -95,9 +62,10 @@ export const createAfterChangeHook = (
95
62
  },
96
63
  })
97
64
 
98
- req.payload.jobs.run().catch((err: unknown) => {
65
+ const runPromise = req.payload.jobs.run().catch((err: unknown) => {
99
66
  req.payload.logger.error({ err }, 'Image optimizer job runner failed')
100
67
  })
68
+ waitUntil(runPromise)
101
69
 
102
70
  return doc
103
71
  }
@@ -4,6 +4,7 @@ import type { CollectionBeforeChangeHook } from 'payload'
4
4
  import type { ResolvedImageOptimizerConfig } from '../types.js'
5
5
  import { resolveCollectionConfig } from '../defaults.js'
6
6
  import { convertFormat, generateThumbHash, stripAndResize } from '../processing/index.js'
7
+ import { isCloudStorage } from '../utilities/storage.js'
7
8
 
8
9
  export const createBeforeChangeHook = (
9
10
  resolvedConfig: ResolvedImageOptimizerConfig,
@@ -45,10 +46,24 @@ export const createBeforeChangeHook = (
45
46
  data.filesize = finalSize
46
47
  }
47
48
 
49
+ // Determine if async work (variant generation job) is needed after create.
50
+ // If not, set status to 'complete' now so afterChange doesn't need a separate
51
+ // update() call — which fails with 404 on MongoDB due to transaction isolation
52
+ // when cloud storage adapters are involved.
53
+ const collectionConfig = req.payload.collections[collectionSlug as keyof typeof req.payload.collections].config
54
+ const cloudStorage = isCloudStorage(collectionConfig)
55
+ const needsAsyncJob = !cloudStorage && perCollectionConfig.formats.length > 0 && !(perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length <= 1)
56
+
48
57
  data.imageOptimizer = {
49
58
  originalSize,
50
59
  optimizedSize: finalSize,
51
- status: 'pending',
60
+ status: needsAsyncJob ? 'pending' : 'complete',
61
+ variants: needsAsyncJob ? undefined : [],
62
+ error: null,
63
+ }
64
+
65
+ if (!needsAsyncJob) {
66
+ context.imageOptimizer_statusResolved = true
52
67
  }
53
68
 
54
69
  if (resolvedConfig.generateThumbHash) {
@@ -115,7 +115,7 @@ export const createConvertFormatsHandler = (resolvedConfig: ResolvedImageOptimiz
115
115
  })
116
116
  } catch (updateErr) {
117
117
  req.payload.logger.error(
118
- { err: updateErr },
118
+ { err: updateErr, docId: input.docId, collectionSlug: input.collectionSlug },
119
119
  'Failed to persist error status for image optimizer',
120
120
  )
121
121
  }
@@ -180,7 +180,7 @@ export const createRegenerateDocumentHandler = (resolvedConfig: ResolvedImageOpt
180
180
  })
181
181
  } catch (updateErr) {
182
182
  req.payload.logger.error(
183
- { err: updateErr },
183
+ { err: updateErr, docId: input.docId, collectionSlug: input.collectionSlug },
184
184
  'Failed to persist error status for image optimizer regeneration',
185
185
  )
186
186
  }
@@ -39,7 +39,14 @@ export async function fetchFileBuffer(
39
39
  const url = doc.url.startsWith('http')
40
40
  ? doc.url
41
41
  : `${process.env.NEXT_PUBLIC_SERVER_URL || ''}${doc.url}`
42
- const response = await fetch(url)
42
+
43
+ if (!url.startsWith('http')) {
44
+ throw new Error(
45
+ `Cannot fetch file "${doc.filename}": URL "${doc.url}" is relative and NEXT_PUBLIC_SERVER_URL is not set`,
46
+ )
47
+ }
48
+
49
+ const response = await fetch(url, { signal: AbortSignal.timeout(30_000) })
43
50
  if (!response.ok) {
44
51
  throw new Error(`Failed to fetch file from ${url}: ${response.status} ${response.statusText}`)
45
52
  }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Extends the serverless function lifetime to keep a promise alive after the
3
+ * response is sent. Uses the Next.js `waitUntil` context when available
4
+ * (Vercel / serverless). In non-serverless environments, the promise runs
5
+ * fire-and-forget as before — Node.js keeps the process alive regardless.
6
+ */
7
+ export function waitUntil(promise: Promise<unknown>): void {
8
+ const ctx = (globalThis as Record<string, unknown>).__next_request_context as
9
+ | { waitUntil?: (p: Promise<unknown>) => void }
10
+ | undefined
11
+
12
+ ctx?.waitUntil?.(promise)
13
+ }