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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -90,7 +90,7 @@ imageOptimizer({
90
90
  | Option | Type | Default | Description |
91
91
  |---|---|---|---|
92
92
  | `collections` | `Record<string, true \| CollectionConfig>` | *required* | Collections to optimize. Use `true` for defaults or an object for overrides. |
93
- | `formats` | `FormatQuality[]` | `[{ format: 'webp', quality: 80 }, { format: 'avif', quality: 65 }]` | Output formats and quality (1-100). |
93
+ | `formats` | `FormatQuality[]` | `[{ format: 'webp', quality: 80 }]` | Output formats and quality (1-100). |
94
94
  | `maxDimensions` | `{ width: number, height: number }` | `{ width: 2560, height: 2560 }` | Maximum image dimensions. Images are resized to fit within these bounds. |
95
95
  | `generateThumbHash` | `boolean` | `true` | Generate ThumbHash blur placeholders for instant image previews. |
96
96
  | `stripMetadata` | `boolean` | `true` | Remove EXIF and other metadata from images. |
@@ -212,6 +212,23 @@ GET /api/image-optimizer/regenerate?collection=media
212
212
 
213
213
  Both endpoints require an authenticated user.
214
214
 
215
+ ## AI Agent Integration
216
+
217
+ Full technical documentation for AI coding agents is available in [`AGENT_DOCS.md`](./AGENT_DOCS.md). It covers all configuration options, field schemas, endpoints, client utilities, background jobs, and context flags in a single reference file.
218
+
219
+ ### Prompt for AI Agents
220
+
221
+ Copy-paste this instruction to your AI coding agent to have it autonomously integrate the plugin:
222
+
223
+ > Install `@inoo-ch/payload-image-optimizer` and integrate it into this Payload CMS project. Read `node_modules/@inoo-ch/payload-image-optimizer/AGENT_DOCS.md` for the full API reference. Based on the project's existing upload collections and frontend, decide on your own:
224
+ >
225
+ > 1. Which upload collections should be optimized and with what settings
226
+ > 2. Whether to use `replaceOriginal` or keep originals alongside variants
227
+ > 3. Where to add `<ImageBox>` or `getImageOptimizerProps()` in the frontend for ThumbHash blur placeholders and focal point support
228
+ > 4. Whether any existing image rendering code should use the optimized variants
229
+ >
230
+ > Use the zero-config default (`collections: { <slug>: true }`) unless the project has specific requirements that call for custom settings.
231
+
215
232
  ## Contributing
216
233
 
217
234
  This plugin is open source and we welcome community involvement:
@@ -1,19 +1,48 @@
1
1
  'use client';
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import React, { useState, useEffect, useCallback, useRef } from 'react';
4
+ const STALL_THRESHOLD = 5;
4
5
  export const RegenerationButton = ()=>{
5
6
  const [isRunning, setIsRunning] = useState(false);
6
7
  const [progress, setProgress] = useState(null);
7
8
  const [queued, setQueued] = useState(null);
8
9
  const [force, setForce] = useState(false);
9
10
  const [error, setError] = useState(null);
11
+ const [stalled, setStalled] = useState(false);
10
12
  const [collectionSlug, setCollectionSlug] = useState(null);
13
+ const [stats, setStats] = useState(null);
11
14
  const intervalRef = useRef(null);
15
+ const stallRef = useRef({
16
+ lastProcessed: 0,
17
+ stallCount: 0
18
+ });
19
+ const prevIsRunningRef = useRef(false);
12
20
  // Extract collection slug from URL after mount to avoid hydration mismatch
13
21
  useEffect(()=>{
14
22
  const slug = window.location.pathname.split('/collections/')[1]?.split('/')[0] ?? null;
15
23
  setCollectionSlug(slug);
16
24
  }, []);
25
+ // Fetch optimization stats (independent of regeneration)
26
+ const fetchStats = useCallback(async ()=>{
27
+ if (!collectionSlug) return;
28
+ try {
29
+ const res = await fetch(`/api/image-optimizer/regenerate?collection=${collectionSlug}`);
30
+ if (res.ok) {
31
+ const data = await res.json();
32
+ setStats(data);
33
+ }
34
+ } catch {
35
+ // ignore stats fetch errors
36
+ }
37
+ }, [
38
+ collectionSlug
39
+ ]);
40
+ const stopPolling = useCallback(()=>{
41
+ if (intervalRef.current) {
42
+ clearInterval(intervalRef.current);
43
+ intervalRef.current = null;
44
+ }
45
+ }, []);
17
46
  const pollProgress = useCallback(async ()=>{
18
47
  if (!collectionSlug) return;
19
48
  try {
@@ -24,24 +53,85 @@ export const RegenerationButton = ()=>{
24
53
  // Stop polling when no more pending
25
54
  if (data.pending <= 0) {
26
55
  setIsRunning(false);
27
- if (intervalRef.current) {
28
- clearInterval(intervalRef.current);
29
- intervalRef.current = null;
30
- }
56
+ stopPolling();
57
+ return;
58
+ }
59
+ // Stall detection
60
+ const processed = data.complete + data.errored;
61
+ if (processed === stallRef.current.lastProcessed) {
62
+ stallRef.current.stallCount += 1;
63
+ } else {
64
+ stallRef.current.stallCount = 0;
65
+ stallRef.current.lastProcessed = processed;
66
+ }
67
+ if (stallRef.current.stallCount >= STALL_THRESHOLD) {
68
+ stopPolling();
69
+ setIsRunning(false);
70
+ setStalled(true);
31
71
  }
32
72
  }
33
73
  } catch {
34
74
  // ignore polling errors
35
75
  }
36
76
  }, [
37
- collectionSlug
77
+ collectionSlug,
78
+ stopPolling
79
+ ]);
80
+ // On mount (once collectionSlug is known), check if there's an ongoing job and resume polling
81
+ useEffect(()=>{
82
+ if (!collectionSlug) return;
83
+ let cancelled = false;
84
+ const checkOngoing = async ()=>{
85
+ try {
86
+ const res = await fetch(`/api/image-optimizer/regenerate?collection=${collectionSlug}`);
87
+ if (!res.ok || cancelled) return;
88
+ const data = await res.json();
89
+ // Always store stats on mount
90
+ setStats(data);
91
+ if (data.pending > 0) {
92
+ setProgress(data);
93
+ setIsRunning(true);
94
+ setStalled(false);
95
+ setQueued(null);
96
+ stallRef.current = {
97
+ lastProcessed: data.complete + data.errored,
98
+ stallCount: 0
99
+ };
100
+ intervalRef.current = setInterval(pollProgress, 2000);
101
+ }
102
+ } catch {
103
+ // ignore
104
+ }
105
+ };
106
+ checkOngoing();
107
+ return ()=>{
108
+ cancelled = true;
109
+ };
110
+ }, [
111
+ collectionSlug,
112
+ pollProgress
113
+ ]);
114
+ // Refresh stats when regeneration finishes (isRunning transitions from true to false)
115
+ useEffect(()=>{
116
+ if (prevIsRunningRef.current && !isRunning) {
117
+ fetchStats();
118
+ }
119
+ prevIsRunningRef.current = isRunning;
120
+ }, [
121
+ isRunning,
122
+ fetchStats
38
123
  ]);
39
124
  const handleRegenerate = async ()=>{
40
125
  if (!collectionSlug) return;
41
126
  setError(null);
127
+ setStalled(false);
42
128
  setIsRunning(true);
43
129
  setQueued(null);
44
130
  setProgress(null);
131
+ stallRef.current = {
132
+ lastProcessed: 0,
133
+ stallCount: 0
134
+ };
45
135
  try {
46
136
  const res = await fetch('/api/image-optimizer/regenerate', {
47
137
  method: 'POST',
@@ -78,6 +168,10 @@ export const RegenerationButton = ()=>{
78
168
  }, []);
79
169
  if (!collectionSlug) return null;
80
170
  const progressPercent = progress && progress.total > 0 ? Math.round(progress.complete / progress.total * 100) : 0;
171
+ const showProgressBar = isRunning && progress || stalled && progress;
172
+ // Stats computations
173
+ const statsPercent = stats && stats.total > 0 ? Math.round(stats.complete / stats.total * 100) : 0;
174
+ const allOptimized = stats && stats.total > 0 && stats.complete === stats.total;
81
175
  return /*#__PURE__*/ _jsxs("div", {
82
176
  style: {
83
177
  padding: '16px 24px',
@@ -127,14 +221,27 @@ export const RegenerationButton = ()=>{
127
221
  },
128
222
  children: error
129
223
  }),
130
- queued === 0 && !isRunning && /*#__PURE__*/ _jsx("span", {
224
+ queued === 0 && !isRunning && !stalled && /*#__PURE__*/ _jsx("span", {
131
225
  style: {
132
226
  color: '#10b981',
133
227
  fontSize: '13px'
134
228
  },
135
229
  children: "All images already optimized."
136
230
  }),
137
- isRunning && progress && /*#__PURE__*/ _jsxs("div", {
231
+ stalled && progress && /*#__PURE__*/ _jsxs("span", {
232
+ style: {
233
+ color: '#f59e0b',
234
+ fontSize: '13px'
235
+ },
236
+ children: [
237
+ "Process stalled. ",
238
+ progress.pending,
239
+ " image",
240
+ progress.pending !== 1 ? 's' : '',
241
+ " failed to process."
242
+ ]
243
+ }),
244
+ showProgressBar && /*#__PURE__*/ _jsxs("div", {
138
245
  style: {
139
246
  flex: 1,
140
247
  minWidth: '200px'
@@ -184,7 +291,7 @@ export const RegenerationButton = ()=>{
184
291
  style: {
185
292
  height: '100%',
186
293
  width: `${progressPercent}%`,
187
- backgroundColor: '#10b981',
294
+ backgroundColor: stalled ? '#f59e0b' : '#10b981',
188
295
  borderRadius: '3px',
189
296
  transition: 'width 0.3s ease'
190
297
  }
@@ -192,17 +299,114 @@ export const RegenerationButton = ()=>{
192
299
  })
193
300
  ]
194
301
  }),
195
- !isRunning && progress && progress.complete > 0 && queued !== 0 && /*#__PURE__*/ _jsxs("span", {
302
+ !isRunning && !stalled && progress && progress.complete > 0 && queued !== 0 && /*#__PURE__*/ _jsxs("span", {
196
303
  style: {
197
- color: '#10b981',
198
304
  fontSize: '13px'
199
305
  },
200
306
  children: [
201
- "Done! ",
202
- progress.complete,
203
- "/",
204
- progress.total,
205
- " optimized."
307
+ /*#__PURE__*/ _jsxs("span", {
308
+ style: {
309
+ color: '#10b981'
310
+ },
311
+ children: [
312
+ "Done! ",
313
+ progress.complete,
314
+ "/",
315
+ progress.total,
316
+ " optimized."
317
+ ]
318
+ }),
319
+ progress.errored > 0 && /*#__PURE__*/ _jsxs("span", {
320
+ style: {
321
+ color: '#ef4444'
322
+ },
323
+ children: [
324
+ ' ',
325
+ progress.errored,
326
+ " failed."
327
+ ]
328
+ })
329
+ ]
330
+ }),
331
+ !isRunning && stats && stats.total > 0 && /*#__PURE__*/ _jsxs("div", {
332
+ style: {
333
+ marginLeft: 'auto',
334
+ display: 'flex',
335
+ flexDirection: 'column',
336
+ alignItems: 'flex-end',
337
+ gap: '4px',
338
+ minWidth: '180px'
339
+ },
340
+ children: [
341
+ /*#__PURE__*/ _jsx("div", {
342
+ style: {
343
+ display: 'flex',
344
+ alignItems: 'center',
345
+ gap: '8px',
346
+ fontSize: '13px'
347
+ },
348
+ children: allOptimized ? /*#__PURE__*/ _jsxs("span", {
349
+ style: {
350
+ color: '#10b981'
351
+ },
352
+ children: [
353
+ "✓ All ",
354
+ stats.total,
355
+ " images optimized"
356
+ ]
357
+ }) : /*#__PURE__*/ _jsxs(_Fragment, {
358
+ children: [
359
+ /*#__PURE__*/ _jsxs("span", {
360
+ style: {
361
+ color: '#6b7280'
362
+ },
363
+ children: [
364
+ stats.complete,
365
+ "/",
366
+ stats.total,
367
+ " optimized"
368
+ ]
369
+ }),
370
+ stats.errored > 0 && /*#__PURE__*/ _jsxs(_Fragment, {
371
+ children: [
372
+ /*#__PURE__*/ _jsx("span", {
373
+ style: {
374
+ color: '#d1d5db'
375
+ },
376
+ children: "·"
377
+ }),
378
+ /*#__PURE__*/ _jsxs("span", {
379
+ style: {
380
+ color: '#ef4444'
381
+ },
382
+ children: [
383
+ stats.errored,
384
+ " errors"
385
+ ]
386
+ })
387
+ ]
388
+ })
389
+ ]
390
+ })
391
+ }),
392
+ !allOptimized && /*#__PURE__*/ _jsx("div", {
393
+ style: {
394
+ width: '100%',
395
+ height: '3px',
396
+ backgroundColor: '#e5e7eb',
397
+ borderRadius: '2px',
398
+ overflow: 'hidden'
399
+ },
400
+ children: /*#__PURE__*/ _jsx("div", {
401
+ style: {
402
+ height: '100%',
403
+ width: `${statsPercent}%`,
404
+ backgroundColor: stats.errored > 0 ? '#f59e0b' : '#10b981',
405
+ borderRadius: '2px',
406
+ transition: 'width 0.3s ease'
407
+ }
408
+ })
409
+ })
206
410
  ]
207
411
  })
208
412
  ]
@@ -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\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 [collectionSlug, setCollectionSlug] = useState<string | null>(null)\n const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)\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 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 = await res.json()\n setProgress(data)\n // Stop polling when no more pending\n if (data.pending <= 0) {\n setIsRunning(false)\n if (intervalRef.current) {\n clearInterval(intervalRef.current)\n intervalRef.current = null\n }\n }\n }\n } catch {\n // ignore polling errors\n }\n }, [collectionSlug])\n\n const handleRegenerate = async () => {\n if (!collectionSlug) return\n setError(null)\n setIsRunning(true)\n setQueued(null)\n setProgress(null)\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.total) * 100)\n : 0\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 && (\n <span style={{ color: '#10b981', fontSize: '13px' }}>\n All images already optimized.\n </span>\n )}\n\n {isRunning && progress && (\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 }}\n >\n <div\n style={{\n height: '100%',\n width: `${progressPercent}%`,\n backgroundColor: '#10b981',\n borderRadius: '3px',\n transition: 'width 0.3s ease',\n }}\n />\n </div>\n </div>\n )}\n\n {!isRunning && progress && progress.complete > 0 && queued !== 0 && (\n <span style={{ color: '#10b981', fontSize: '13px' }}>\n Done! {progress.complete}/{progress.total} optimized.\n </span>\n )}\n </div>\n )\n}\n"],"names":["React","useState","useEffect","useCallback","useRef","RegenerationButton","isRunning","setIsRunning","progress","setProgress","queued","setQueued","force","setForce","error","setError","collectionSlug","setCollectionSlug","intervalRef","slug","window","location","pathname","split","pollProgress","res","fetch","ok","data","json","pending","current","clearInterval","handleRegenerate","method","headers","body","JSON","stringify","Error","setInterval","err","message","String","progressPercent","total","Math","round","complete","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","errored","height","overflow","width","transition"],"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 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.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 Process stalled. {progress.pending} image{progress.pending !== 1 ? 's' : ''} failed to process.\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 }}\n >\n <div\n style={{\n height: '100%',\n width: `${progressPercent}%`,\n backgroundColor: stalled ? '#f59e0b' : '#10b981',\n borderRadius: '3px',\n transition: 'width 0.3s ease',\n }}\n />\n </div>\n </div>\n )}\n\n {!isRunning && !stalled && progress && progress.complete > 0 && queued !== 0 && (\n <span style={{ fontSize: '13px' }}>\n <span style={{ color: '#10b981' }}>\n Done! {progress.complete}/{progress.total} optimized.\n </span>\n {progress.errored > 0 && (\n <span style={{ color: '#ef4444' }}>\n {' '}{progress.errored} 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":"AAAA;;AAEA,OAAOA,SAASC,QAAQ,EAAEC,SAAS,EAAEC,WAAW,EAAEC,MAAM,QAAQ,QAAO;AASvE,MAAMC,kBAAkB;AAExB,OAAO,MAAMC,qBAA+B;IAC1C,MAAM,CAACC,WAAWC,aAAa,GAAGP,SAAS;IAC3C,MAAM,CAACQ,UAAUC,YAAY,GAAGT,SAAsC;IACtE,MAAM,CAACU,QAAQC,UAAU,GAAGX,SAAwB;IACpD,MAAM,CAACY,OAAOC,SAAS,GAAGb,SAAS;IACnC,MAAM,CAACc,OAAOC,SAAS,GAAGf,SAAwB;IAClD,MAAM,CAACgB,SAASC,WAAW,GAAGjB,SAAS;IACvC,MAAM,CAACkB,gBAAgBC,kBAAkB,GAAGnB,SAAwB;IACpE,MAAM,CAACoB,OAAOC,SAAS,GAAGrB,SAAsC;IAChE,MAAMsB,cAAcnB,OAA8C;IAClE,MAAMoB,WAAWpB,OAAO;QAAEqB,eAAe;QAAGC,YAAY;IAAE;IAC1D,MAAMC,mBAAmBvB,OAAO;IAEhC,2EAA2E;IAC3EF,UAAU;QACR,MAAM0B,OAAOC,OAAOC,QAAQ,CAACC,QAAQ,CAACC,KAAK,CAAC,gBAAgB,CAAC,EAAE,EAAEA,MAAM,IAAI,CAAC,EAAE,IAAI;QAClFZ,kBAAkBQ;IACpB,GAAG,EAAE;IAEL,yDAAyD;IACzD,MAAMK,aAAa9B,YAAY;QAC7B,IAAI,CAACgB,gBAAgB;QACrB,IAAI;YACF,MAAMe,MAAM,MAAMC,MAChB,CAAC,2CAA2C,EAAEhB,gBAAgB;YAEhE,IAAIe,IAAIE,EAAE,EAAE;gBACV,MAAMC,OAA6B,MAAMH,IAAII,IAAI;gBACjDhB,SAASe;YACX;QACF,EAAE,OAAM;QACN,4BAA4B;QAC9B;IACF,GAAG;QAAClB;KAAe;IAEnB,MAAMoB,cAAcpC,YAAY;QAC9B,IAAIoB,YAAYiB,OAAO,EAAE;YACvBC,cAAclB,YAAYiB,OAAO;YACjCjB,YAAYiB,OAAO,GAAG;QACxB;IACF,GAAG,EAAE;IAEL,MAAME,eAAevC,YAAY;QAC/B,IAAI,CAACgB,gBAAgB;QACrB,IAAI;YACF,MAAMe,MAAM,MAAMC,MAChB,CAAC,2CAA2C,EAAEhB,gBAAgB;YAEhE,IAAIe,IAAIE,EAAE,EAAE;gBACV,MAAMC,OAA6B,MAAMH,IAAII,IAAI;gBACjD5B,YAAY2B;gBAEZ,oCAAoC;gBACpC,IAAIA,KAAKM,OAAO,IAAI,GAAG;oBACrBnC,aAAa;oBACb+B;oBACA;gBACF;gBAEA,kBAAkB;gBAClB,MAAMK,YAAYP,KAAKQ,QAAQ,GAAGR,KAAKS,OAAO;gBAC9C,IAAIF,cAAcpB,SAASgB,OAAO,CAACf,aAAa,EAAE;oBAChDD,SAASgB,OAAO,CAACd,UAAU,IAAI;gBACjC,OAAO;oBACLF,SAASgB,OAAO,CAACd,UAAU,GAAG;oBAC9BF,SAASgB,OAAO,CAACf,aAAa,GAAGmB;gBACnC;gBAEA,IAAIpB,SAASgB,OAAO,CAACd,UAAU,IAAIrB,iBAAiB;oBAClDkC;oBACA/B,aAAa;oBACbU,WAAW;gBACb;YACF;QACF,EAAE,OAAM;QACN,wBAAwB;QAC1B;IACF,GAAG;QAACC;QAAgBoB;KAAY;IAEhC,8FAA8F;IAC9FrC,UAAU;QACR,IAAI,CAACiB,gBAAgB;QACrB,IAAI4B,YAAY;QAChB,MAAMC,eAAe;YACnB,IAAI;gBACF,MAAMd,MAAM,MAAMC,MAChB,CAAC,2CAA2C,EAAEhB,gBAAgB;gBAEhE,IAAI,CAACe,IAAIE,EAAE,IAAIW,WAAW;gBAC1B,MAAMV,OAA6B,MAAMH,IAAII,IAAI;gBACjD,8BAA8B;gBAC9BhB,SAASe;gBACT,IAAIA,KAAKM,OAAO,GAAG,GAAG;oBACpBjC,YAAY2B;oBACZ7B,aAAa;oBACbU,WAAW;oBACXN,UAAU;oBACVY,SAASgB,OAAO,GAAG;wBAAEf,eAAeY,KAAKQ,QAAQ,GAAGR,KAAKS,OAAO;wBAAEpB,YAAY;oBAAE;oBAChFH,YAAYiB,OAAO,GAAGS,YAAYP,cAAc;gBAClD;YACF,EAAE,OAAM;YACN,SAAS;YACX;QACF;QACAM;QACA,OAAO;YACLD,YAAY;QACd;IACF,GAAG;QAAC5B;QAAgBuB;KAAa;IAEjC,sFAAsF;IACtFxC,UAAU;QACR,IAAIyB,iBAAiBa,OAAO,IAAI,CAACjC,WAAW;YAC1C0B;QACF;QACAN,iBAAiBa,OAAO,GAAGjC;IAC7B,GAAG;QAACA;QAAW0B;KAAW;IAE1B,MAAMiB,mBAAmB;QACvB,IAAI,CAAC/B,gBAAgB;QACrBH,SAAS;QACTE,WAAW;QACXV,aAAa;QACbI,UAAU;QACVF,YAAY;QACZc,SAASgB,OAAO,GAAG;YAAEf,eAAe;YAAGC,YAAY;QAAE;QAErD,IAAI;YACF,MAAMQ,MAAM,MAAMC,MAAM,mCAAmC;gBACzDgB,QAAQ;gBACRC,SAAS;oBAAE,gBAAgB;gBAAmB;gBAC9CC,MAAMC,KAAKC,SAAS,CAAC;oBAAEpC;oBAAgBN;gBAAM;YAC/C;YAEA,IAAI,CAACqB,IAAIE,EAAE,EAAE;gBACX,MAAMC,OAAO,MAAMH,IAAII,IAAI;gBAC3B,MAAM,IAAIkB,MAAMnB,KAAKtB,KAAK,IAAI;YAChC;YAEA,MAAMsB,OAAO,MAAMH,IAAII,IAAI;YAC3B1B,UAAUyB,KAAK1B,MAAM;YAErB,IAAI0B,KAAK1B,MAAM,KAAK,GAAG;gBACrBH,aAAa;gBACb;YACF;YAEA,gBAAgB;YAChBe,YAAYiB,OAAO,GAAGS,YAAYP,cAAc;QAClD,EAAE,OAAOe,KAAK;YACZzC,SAASyC,eAAeD,QAAQC,IAAIC,OAAO,GAAGC,OAAOF;YACrDjD,aAAa;QACf;IACF;IAEA,8BAA8B;IAC9BN,UAAU;QACR,OAAO;YACL,IAAIqB,YAAYiB,OAAO,EAAEC,cAAclB,YAAYiB,OAAO;QAC5D;IACF,GAAG,EAAE;IAEL,IAAI,CAACrB,gBAAgB,OAAO;IAE5B,MAAMyC,kBACJnD,YAAYA,SAASoD,KAAK,GAAG,IACzBC,KAAKC,KAAK,CAAC,AAACtD,SAASoC,QAAQ,GAAGpC,SAASoD,KAAK,GAAI,OAClD;IAEN,MAAMG,kBAAkB,AAACzD,aAAaE,YAAcQ,WAAWR;IAE/D,qBAAqB;IACrB,MAAMwD,eACJ5C,SAASA,MAAMwC,KAAK,GAAG,IACnBC,KAAKC,KAAK,CAAC,AAAC1C,MAAMwB,QAAQ,GAAGxB,MAAMwC,KAAK,GAAI,OAC5C;IACN,MAAMK,eAAe7C,SAASA,MAAMwC,KAAK,GAAG,KAAKxC,MAAMwB,QAAQ,KAAKxB,MAAMwC,KAAK;IAE/E,qBACE,MAACM;QACCC,OAAO;YACLC,SAAS;YACTC,cAAc;YACdC,SAAS;YACTC,YAAY;YACZC,KAAK;YACLC,UAAU;QACZ;;0BAEA,KAACC;gBACCC,SAAS1B;gBACT2B,UAAUtE;gBACV6D,OAAO;oBACLU,iBAAiBvE,YAAY,YAAY;oBACzCwE,OAAO;oBACPC,QAAQ;oBACRC,cAAc;oBACdZ,SAAS;oBACTa,UAAU;oBACVC,YAAY;oBACZC,QAAQ7E,YAAY,gBAAgB;gBACtC;0BAECA,YAAY,oBAAoB;;0BAGnC,MAAC8E;gBACCjB,OAAO;oBAAEG,SAAS;oBAAQC,YAAY;oBAAUC,KAAK;oBAAOS,UAAU;gBAAO;;kCAE7E,KAACI;wBACCC,MAAK;wBACLC,SAAS3E;wBACT4E,UAAU,CAACC,IAAM5E,SAAS4E,EAAEC,MAAM,CAACH,OAAO;wBAC1CX,UAAUtE;;oBACV;;;YAIHQ,uBACC,KAAC6E;gBAAKxB,OAAO;oBAAEW,OAAO;oBAAWG,UAAU;gBAAO;0BAAInE;;YAGvDJ,WAAW,KAAK,CAACJ,aAAa,CAACU,yBAC9B,KAAC2E;gBAAKxB,OAAO;oBAAEW,OAAO;oBAAWG,UAAU;gBAAO;0BAAG;;YAKtDjE,WAAWR,0BACV,MAACmF;gBAAKxB,OAAO;oBAAEW,OAAO;oBAAWG,UAAU;gBAAO;;oBAAG;oBACjCzE,SAASkC,OAAO;oBAAC;oBAAOlC,SAASkC,OAAO,KAAK,IAAI,MAAM;oBAAG;;;YAI/EqB,iCACC,MAACG;gBAAIC,OAAO;oBAAEyB,MAAM;oBAAGC,UAAU;gBAAQ;;kCACvC,MAAC3B;wBACCC,OAAO;4BACLG,SAAS;4BACTwB,gBAAgB;4BAChBb,UAAU;4BACVc,cAAc;wBAChB;;0CAEA,MAACJ;;oCACEnF,SAASoC,QAAQ;oCAAC;oCAAIpC,SAASoD,KAAK;oCAAC;;;4BAEvCpD,SAASqC,OAAO,GAAG,mBAClB,MAAC8C;gCAAKxB,OAAO;oCAAEW,OAAO;gCAAU;;oCAAItE,SAASqC,OAAO;oCAAC;;;0CAEvD,MAAC8C;;oCAAMhC;oCAAgB;;;;;kCAEzB,KAACO;wBACCC,OAAO;4BACL6B,QAAQ;4BACRnB,iBAAiB;4BACjBG,cAAc;4BACdiB,UAAU;wBACZ;kCAEA,cAAA,KAAC/B;4BACCC,OAAO;gCACL6B,QAAQ;gCACRE,OAAO,GAAGvC,gBAAgB,CAAC,CAAC;gCAC5BkB,iBAAiB7D,UAAU,YAAY;gCACvCgE,cAAc;gCACdmB,YAAY;4BACd;;;;;YAMP,CAAC7F,aAAa,CAACU,WAAWR,YAAYA,SAASoC,QAAQ,GAAG,KAAKlC,WAAW,mBACzE,MAACiF;gBAAKxB,OAAO;oBAAEc,UAAU;gBAAO;;kCAC9B,MAACU;wBAAKxB,OAAO;4BAAEW,OAAO;wBAAU;;4BAAG;4BAC1BtE,SAASoC,QAAQ;4BAAC;4BAAEpC,SAASoD,KAAK;4BAAC;;;oBAE3CpD,SAASqC,OAAO,GAAG,mBAClB,MAAC8C;wBAAKxB,OAAO;4BAAEW,OAAO;wBAAU;;4BAC7B;4BAAKtE,SAASqC,OAAO;4BAAC;;;;;YAO9B,CAACvC,aAAac,SAASA,MAAMwC,KAAK,GAAG,mBACpC,MAACM;gBACCC,OAAO;oBACLiC,YAAY;oBACZ9B,SAAS;oBACT+B,eAAe;oBACf9B,YAAY;oBACZC,KAAK;oBACLqB,UAAU;gBACZ;;kCAEA,KAAC3B;wBAAIC,OAAO;4BAAEG,SAAS;4BAAQC,YAAY;4BAAUC,KAAK;4BAAOS,UAAU;wBAAO;kCAC/EhB,6BACC,MAAC0B;4BAAKxB,OAAO;gCAAEW,OAAO;4BAAU;;gCAAG;gCACnB1D,MAAMwC,KAAK;gCAAC;;2CAG5B;;8CACE,MAAC+B;oCAAKxB,OAAO;wCAAEW,OAAO;oCAAU;;wCAC7B1D,MAAMwB,QAAQ;wCAAC;wCAAExB,MAAMwC,KAAK;wCAAC;;;gCAE/BxC,MAAMyB,OAAO,GAAG,mBACf;;sDACE,KAAC8C;4CAAKxB,OAAO;gDAAEW,OAAO;4CAAU;sDAAG;;sDACnC,MAACa;4CAAKxB,OAAO;gDAAEW,OAAO;4CAAU;;gDAAI1D,MAAMyB,OAAO;gDAAC;;;;;;;;oBAM3D,CAACoB,8BACA,KAACC;wBACCC,OAAO;4BACL+B,OAAO;4BACPF,QAAQ;4BACRnB,iBAAiB;4BACjBG,cAAc;4BACdiB,UAAU;wBACZ;kCAEA,cAAA,KAAC/B;4BACCC,OAAO;gCACL6B,QAAQ;gCACRE,OAAO,GAAGlC,aAAa,CAAC,CAAC;gCACzBa,iBAAiBzD,MAAMyB,OAAO,GAAG,IAAI,YAAY;gCACjDmC,cAAc;gCACdmB,YAAY;4BACd;;;;;;;AAQhB,EAAC"}
package/dist/defaults.js CHANGED
@@ -5,10 +5,6 @@ export const resolveConfig = (config)=>({
5
5
  {
6
6
  format: 'webp',
7
7
  quality: 80
8
- },
9
- {
10
- format: 'avif',
11
- quality: 65
12
8
  }
13
9
  ],
14
10
  generateThumbHash: config.generateThumbHash ?? true,
@@ -16,6 +12,7 @@ export const resolveConfig = (config)=>({
16
12
  width: 2560,
17
13
  height: 2560
18
14
  },
15
+ replaceOriginal: config.replaceOriginal ?? true,
19
16
  stripMetadata: config.stripMetadata ?? true
20
17
  });
21
18
  export const resolveCollectionConfig = (resolvedConfig, collectionSlug)=>{
@@ -23,12 +20,14 @@ export const resolveCollectionConfig = (resolvedConfig, collectionSlug)=>{
23
20
  if (!collectionValue || collectionValue === true) {
24
21
  return {
25
22
  formats: resolvedConfig.formats,
26
- maxDimensions: resolvedConfig.maxDimensions
23
+ maxDimensions: resolvedConfig.maxDimensions,
24
+ replaceOriginal: resolvedConfig.replaceOriginal
27
25
  };
28
26
  }
29
27
  return {
30
28
  formats: collectionValue.formats ?? resolvedConfig.formats,
31
- maxDimensions: collectionValue.maxDimensions ?? resolvedConfig.maxDimensions
29
+ maxDimensions: collectionValue.maxDimensions ?? resolvedConfig.maxDimensions,
30
+ replaceOriginal: collectionValue.replaceOriginal ?? resolvedConfig.replaceOriginal
32
31
  };
33
32
  };
34
33
 
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/defaults.ts"],"sourcesContent":["import type { CollectionSlug } from 'payload'\n\nimport type { ImageOptimizerConfig, ResolvedCollectionOptimizerConfig, ResolvedImageOptimizerConfig } from './types.js'\n\nexport const resolveConfig = (config: ImageOptimizerConfig): ResolvedImageOptimizerConfig => ({\n collections: config.collections,\n disabled: config.disabled ?? false,\n formats: config.formats ?? [\n { format: 'webp', quality: 80 },\n { format: 'avif', quality: 65 },\n ],\n generateThumbHash: config.generateThumbHash ?? true,\n maxDimensions: config.maxDimensions ?? { width: 2560, height: 2560 },\n stripMetadata: config.stripMetadata ?? true,\n})\n\nexport const resolveCollectionConfig = (\n resolvedConfig: ResolvedImageOptimizerConfig,\n collectionSlug: string,\n): ResolvedCollectionOptimizerConfig => {\n const collectionValue = resolvedConfig.collections[collectionSlug as CollectionSlug]\n\n if (!collectionValue || collectionValue === true) {\n return {\n formats: resolvedConfig.formats,\n maxDimensions: resolvedConfig.maxDimensions,\n }\n }\n\n return {\n formats: collectionValue.formats ?? resolvedConfig.formats,\n maxDimensions: collectionValue.maxDimensions ?? resolvedConfig.maxDimensions,\n }\n}\n"],"names":["resolveConfig","config","collections","disabled","formats","format","quality","generateThumbHash","maxDimensions","width","height","stripMetadata","resolveCollectionConfig","resolvedConfig","collectionSlug","collectionValue"],"mappings":"AAIA,OAAO,MAAMA,gBAAgB,CAACC,SAAgE,CAAA;QAC5FC,aAAaD,OAAOC,WAAW;QAC/BC,UAAUF,OAAOE,QAAQ,IAAI;QAC7BC,SAASH,OAAOG,OAAO,IAAI;YACzB;gBAAEC,QAAQ;gBAAQC,SAAS;YAAG;YAC9B;gBAAED,QAAQ;gBAAQC,SAAS;YAAG;SAC/B;QACDC,mBAAmBN,OAAOM,iBAAiB,IAAI;QAC/CC,eAAeP,OAAOO,aAAa,IAAI;YAAEC,OAAO;YAAMC,QAAQ;QAAK;QACnEC,eAAeV,OAAOU,aAAa,IAAI;IACzC,CAAA,EAAE;AAEF,OAAO,MAAMC,0BAA0B,CACrCC,gBACAC;IAEA,MAAMC,kBAAkBF,eAAeX,WAAW,CAACY,eAAiC;IAEpF,IAAI,CAACC,mBAAmBA,oBAAoB,MAAM;QAChD,OAAO;YACLX,SAASS,eAAeT,OAAO;YAC/BI,eAAeK,eAAeL,aAAa;QAC7C;IACF;IAEA,OAAO;QACLJ,SAASW,gBAAgBX,OAAO,IAAIS,eAAeT,OAAO;QAC1DI,eAAeO,gBAAgBP,aAAa,IAAIK,eAAeL,aAAa;IAC9E;AACF,EAAC"}
1
+ {"version":3,"sources":["../src/defaults.ts"],"sourcesContent":["import type { CollectionSlug } from 'payload'\n\nimport type { ImageOptimizerConfig, ResolvedCollectionOptimizerConfig, ResolvedImageOptimizerConfig } from './types.js'\n\nexport const resolveConfig = (config: ImageOptimizerConfig): ResolvedImageOptimizerConfig => ({\n collections: config.collections,\n disabled: config.disabled ?? false,\n formats: config.formats ?? [\n { format: 'webp', quality: 80 },\n ],\n generateThumbHash: config.generateThumbHash ?? true,\n maxDimensions: config.maxDimensions ?? { width: 2560, height: 2560 },\n replaceOriginal: config.replaceOriginal ?? true,\n stripMetadata: config.stripMetadata ?? true,\n})\n\nexport const resolveCollectionConfig = (\n resolvedConfig: ResolvedImageOptimizerConfig,\n collectionSlug: string,\n): ResolvedCollectionOptimizerConfig => {\n const collectionValue = resolvedConfig.collections[collectionSlug as CollectionSlug]\n\n if (!collectionValue || collectionValue === true) {\n return {\n formats: resolvedConfig.formats,\n maxDimensions: resolvedConfig.maxDimensions,\n replaceOriginal: resolvedConfig.replaceOriginal,\n }\n }\n\n return {\n formats: collectionValue.formats ?? resolvedConfig.formats,\n maxDimensions: collectionValue.maxDimensions ?? resolvedConfig.maxDimensions,\n replaceOriginal: collectionValue.replaceOriginal ?? resolvedConfig.replaceOriginal,\n }\n}\n"],"names":["resolveConfig","config","collections","disabled","formats","format","quality","generateThumbHash","maxDimensions","width","height","replaceOriginal","stripMetadata","resolveCollectionConfig","resolvedConfig","collectionSlug","collectionValue"],"mappings":"AAIA,OAAO,MAAMA,gBAAgB,CAACC,SAAgE,CAAA;QAC5FC,aAAaD,OAAOC,WAAW;QAC/BC,UAAUF,OAAOE,QAAQ,IAAI;QAC7BC,SAASH,OAAOG,OAAO,IAAI;YACzB;gBAAEC,QAAQ;gBAAQC,SAAS;YAAG;SAC/B;QACDC,mBAAmBN,OAAOM,iBAAiB,IAAI;QAC/CC,eAAeP,OAAOO,aAAa,IAAI;YAAEC,OAAO;YAAMC,QAAQ;QAAK;QACnEC,iBAAiBV,OAAOU,eAAe,IAAI;QAC3CC,eAAeX,OAAOW,aAAa,IAAI;IACzC,CAAA,EAAE;AAEF,OAAO,MAAMC,0BAA0B,CACrCC,gBACAC;IAEA,MAAMC,kBAAkBF,eAAeZ,WAAW,CAACa,eAAiC;IAEpF,IAAI,CAACC,mBAAmBA,oBAAoB,MAAM;QAChD,OAAO;YACLZ,SAASU,eAAeV,OAAO;YAC/BI,eAAeM,eAAeN,aAAa;YAC3CG,iBAAiBG,eAAeH,eAAe;QACjD;IACF;IAEA,OAAO;QACLP,SAASY,gBAAgBZ,OAAO,IAAIU,eAAeV,OAAO;QAC1DI,eAAeQ,gBAAgBR,aAAa,IAAIM,eAAeN,aAAa;QAC5EG,iBAAiBK,gBAAgBL,eAAe,IAAIG,eAAeH,eAAe;IACpF;AACF,EAAC"}
@@ -1,26 +1,51 @@
1
1
  import fs from 'fs/promises';
2
2
  import path from 'path';
3
+ import { resolveCollectionConfig } from '../defaults.js';
3
4
  export const createAfterChangeHook = (resolvedConfig, collectionSlug)=>{
4
5
  return async ({ context, doc, req })=>{
5
6
  if (context?.imageOptimizer_skip) return doc;
6
7
  if (!req.file || !req.file.data || !req.file.mimetype?.startsWith('image/')) return doc;
7
- // Overwrite the file on disk with the processed (stripped/resized) buffer
8
+ const collectionConfig = req.payload.collections[collectionSlug].config;
9
+ let staticDir = typeof collectionConfig.upload === 'object' ? collectionConfig.upload.staticDir || '' : '';
10
+ if (staticDir && !path.isAbsolute(staticDir)) {
11
+ staticDir = path.resolve(process.cwd(), staticDir);
12
+ }
13
+ const perCollectionConfig = resolveCollectionConfig(resolvedConfig, collectionSlug);
14
+ // Overwrite the file on disk with the processed (stripped/resized/converted) buffer
8
15
  // Payload 3.0 writes the original buffer to disk; we replace it here
9
16
  const processedBuffer = context.imageOptimizer_processedBuffer;
10
- if (processedBuffer && doc.filename) {
11
- const collectionConfig = req.payload.collections[collectionSlug].config;
12
- let staticDir = typeof collectionConfig.upload === 'object' ? collectionConfig.upload.staticDir || '' : '';
13
- if (staticDir && !path.isAbsolute(staticDir)) {
14
- staticDir = path.resolve(process.cwd(), staticDir);
15
- }
16
- if (staticDir) {
17
- // Sanitize filename to prevent path traversal
18
- const safeFilename = path.basename(doc.filename);
19
- const filePath = path.join(staticDir, safeFilename);
20
- await fs.writeFile(filePath, processedBuffer);
17
+ if (processedBuffer && doc.filename && staticDir) {
18
+ const safeFilename = path.basename(doc.filename);
19
+ const filePath = path.join(staticDir, safeFilename);
20
+ await fs.writeFile(filePath, processedBuffer);
21
+ // If replaceOriginal changed the filename, clean up the old file Payload wrote
22
+ const originalFilename = context.imageOptimizer_originalFilename;
23
+ if (originalFilename && originalFilename !== safeFilename) {
24
+ const oldFilePath = path.join(staticDir, path.basename(originalFilename));
25
+ await fs.unlink(oldFilePath).catch(()=>{
26
+ // Old file may not exist if Payload used the new filename
27
+ });
21
28
  }
22
29
  }
23
- // Queue async format conversion job
30
+ // When replaceOriginal is on and only one format is configured, the main file
31
+ // is already converted — skip the async job and mark complete immediately.
32
+ if (perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length <= 1) {
33
+ await req.payload.update({
34
+ collection: collectionSlug,
35
+ id: doc.id,
36
+ data: {
37
+ imageOptimizer: {
38
+ status: 'complete',
39
+ variants: []
40
+ }
41
+ },
42
+ context: {
43
+ imageOptimizer_skip: true
44
+ }
45
+ });
46
+ return doc;
47
+ }
48
+ // Queue async format conversion job for remaining variants
24
49
  await req.payload.jobs.queue({
25
50
  task: 'imageOptimizer_convertFormats',
26
51
  input: {
@@ -1 +1 @@
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'\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 if (!req.file || !req.file.data || !req.file.mimetype?.startsWith('image/')) return doc\n\n // Overwrite the file on disk with the processed (stripped/resized) buffer\n // Payload 3.0 writes the original buffer to disk; we replace it here\n const processedBuffer = context.imageOptimizer_processedBuffer as Buffer | undefined\n if (processedBuffer && doc.filename) {\n const collectionConfig = req.payload.collections[collectionSlug as keyof typeof req.payload.collections].config\n let staticDir: string =\n typeof collectionConfig.upload === 'object' ? collectionConfig.upload.staticDir || '' : ''\n\n if (staticDir && !path.isAbsolute(staticDir)) {\n staticDir = path.resolve(process.cwd(), staticDir)\n }\n\n if (staticDir) {\n // Sanitize filename to prevent path traversal\n const safeFilename = path.basename(doc.filename as string)\n const filePath = path.join(staticDir, safeFilename)\n await fs.writeFile(filePath, processedBuffer)\n }\n }\n\n // Queue async format conversion job\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","createAfterChangeHook","resolvedConfig","collectionSlug","context","doc","req","imageOptimizer_skip","file","data","mimetype","startsWith","processedBuffer","imageOptimizer_processedBuffer","filename","collectionConfig","payload","collections","config","staticDir","upload","isAbsolute","resolve","process","cwd","safeFilename","basename","filePath","join","writeFile","jobs","queue","task","input","docId","String","id","run","catch","err","logger","error"],"mappings":"AAAA,OAAOA,QAAQ,cAAa;AAC5B,OAAOC,UAAU,OAAM;AAKvB,OAAO,MAAMC,wBAAwB,CACnCC,gBACAC;IAEA,OAAO,OAAO,EAAEC,OAAO,EAAEC,GAAG,EAAEC,GAAG,EAAE;QACjC,IAAIF,SAASG,qBAAqB,OAAOF;QAEzC,IAAI,CAACC,IAAIE,IAAI,IAAI,CAACF,IAAIE,IAAI,CAACC,IAAI,IAAI,CAACH,IAAIE,IAAI,CAACE,QAAQ,EAAEC,WAAW,WAAW,OAAON;QAEpF,0EAA0E;QAC1E,qEAAqE;QACrE,MAAMO,kBAAkBR,QAAQS,8BAA8B;QAC9D,IAAID,mBAAmBP,IAAIS,QAAQ,EAAE;YACnC,MAAMC,mBAAmBT,IAAIU,OAAO,CAACC,WAAW,CAACd,eAAuD,CAACe,MAAM;YAC/G,IAAIC,YACF,OAAOJ,iBAAiBK,MAAM,KAAK,WAAWL,iBAAiBK,MAAM,CAACD,SAAS,IAAI,KAAK;YAE1F,IAAIA,aAAa,CAACnB,KAAKqB,UAAU,CAACF,YAAY;gBAC5CA,YAAYnB,KAAKsB,OAAO,CAACC,QAAQC,GAAG,IAAIL;YAC1C;YAEA,IAAIA,WAAW;gBACb,8CAA8C;gBAC9C,MAAMM,eAAezB,KAAK0B,QAAQ,CAACrB,IAAIS,QAAQ;gBAC/C,MAAMa,WAAW3B,KAAK4B,IAAI,CAACT,WAAWM;gBACtC,MAAM1B,GAAG8B,SAAS,CAACF,UAAUf;YAC/B;QACF;QAEA,oCAAoC;QACpC,MAAMN,IAAIU,OAAO,CAACc,IAAI,CAACC,KAAK,CAAC;YAC3BC,MAAM;YACNC,OAAO;gBACL9B;gBACA+B,OAAOC,OAAO9B,IAAI+B,EAAE;YACtB;QACF;QAEA9B,IAAIU,OAAO,CAACc,IAAI,CAACO,GAAG,GAAGC,KAAK,CAAC,CAACC;YAC5BjC,IAAIU,OAAO,CAACwB,MAAM,CAACC,KAAK,CAAC;gBAAEF;YAAI,GAAG;QACpC;QAEA,OAAOlC;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 { resolveCollectionConfig } from '../defaults.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 if (!req.file || !req.file.data || !req.file.mimetype?.startsWith('image/')) return doc\n\n const collectionConfig = req.payload.collections[collectionSlug as keyof typeof req.payload.collections].config\n let staticDir: string =\n typeof collectionConfig.upload === 'object' ? collectionConfig.upload.staticDir || '' : ''\n\n if (staticDir && !path.isAbsolute(staticDir)) {\n staticDir = path.resolve(process.cwd(), staticDir)\n }\n\n const perCollectionConfig = resolveCollectionConfig(resolvedConfig, collectionSlug)\n\n // Overwrite the file on disk with the processed (stripped/resized/converted) buffer\n // Payload 3.0 writes the original buffer to disk; we replace it here\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 // 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,\n id: doc.id,\n data: {\n imageOptimizer: {\n status: 'complete',\n variants: [],\n },\n },\n context: { imageOptimizer_skip: true },\n })\n return doc\n }\n\n // Queue async format conversion job for remaining variants\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","createAfterChangeHook","resolvedConfig","collectionSlug","context","doc","req","imageOptimizer_skip","file","data","mimetype","startsWith","collectionConfig","payload","collections","config","staticDir","upload","isAbsolute","resolve","process","cwd","perCollectionConfig","processedBuffer","imageOptimizer_processedBuffer","filename","safeFilename","basename","filePath","join","writeFile","originalFilename","imageOptimizer_originalFilename","oldFilePath","unlink","catch","replaceOriginal","formats","length","update","collection","id","imageOptimizer","status","variants","jobs","queue","task","input","docId","String","run","err","logger","error"],"mappings":"AAAA,OAAOA,QAAQ,cAAa;AAC5B,OAAOC,UAAU,OAAM;AAIvB,SAASC,uBAAuB,QAAQ,iBAAgB;AAExD,OAAO,MAAMC,wBAAwB,CACnCC,gBACAC;IAEA,OAAO,OAAO,EAAEC,OAAO,EAAEC,GAAG,EAAEC,GAAG,EAAE;QACjC,IAAIF,SAASG,qBAAqB,OAAOF;QAEzC,IAAI,CAACC,IAAIE,IAAI,IAAI,CAACF,IAAIE,IAAI,CAACC,IAAI,IAAI,CAACH,IAAIE,IAAI,CAACE,QAAQ,EAAEC,WAAW,WAAW,OAAON;QAEpF,MAAMO,mBAAmBN,IAAIO,OAAO,CAACC,WAAW,CAACX,eAAuD,CAACY,MAAM;QAC/G,IAAIC,YACF,OAAOJ,iBAAiBK,MAAM,KAAK,WAAWL,iBAAiBK,MAAM,CAACD,SAAS,IAAI,KAAK;QAE1F,IAAIA,aAAa,CAACjB,KAAKmB,UAAU,CAACF,YAAY;YAC5CA,YAAYjB,KAAKoB,OAAO,CAACC,QAAQC,GAAG,IAAIL;QAC1C;QAEA,MAAMM,sBAAsBtB,wBAAwBE,gBAAgBC;QAEpE,oFAAoF;QACpF,qEAAqE;QACrE,MAAMoB,kBAAkBnB,QAAQoB,8BAA8B;QAC9D,IAAID,mBAAmBlB,IAAIoB,QAAQ,IAAIT,WAAW;YAChD,MAAMU,eAAe3B,KAAK4B,QAAQ,CAACtB,IAAIoB,QAAQ;YAC/C,MAAMG,WAAW7B,KAAK8B,IAAI,CAACb,WAAWU;YACtC,MAAM5B,GAAGgC,SAAS,CAACF,UAAUL;YAE7B,+EAA+E;YAC/E,MAAMQ,mBAAmB3B,QAAQ4B,+BAA+B;YAChE,IAAID,oBAAoBA,qBAAqBL,cAAc;gBACzD,MAAMO,cAAclC,KAAK8B,IAAI,CAACb,WAAWjB,KAAK4B,QAAQ,CAACI;gBACvD,MAAMjC,GAAGoC,MAAM,CAACD,aAAaE,KAAK,CAAC;gBACjC,0DAA0D;gBAC5D;YACF;QACF;QAEA,8EAA8E;QAC9E,2EAA2E;QAC3E,IAAIb,oBAAoBc,eAAe,IAAId,oBAAoBe,OAAO,CAACC,MAAM,IAAI,GAAG;YAClF,MAAMhC,IAAIO,OAAO,CAAC0B,MAAM,CAAC;gBACvBC,YAAYrC;gBACZsC,IAAIpC,IAAIoC,EAAE;gBACVhC,MAAM;oBACJiC,gBAAgB;wBACdC,QAAQ;wBACRC,UAAU,EAAE;oBACd;gBACF;gBACAxC,SAAS;oBAAEG,qBAAqB;gBAAK;YACvC;YACA,OAAOF;QACT;QAEA,2DAA2D;QAC3D,MAAMC,IAAIO,OAAO,CAACgC,IAAI,CAACC,KAAK,CAAC;YAC3BC,MAAM;YACNC,OAAO;gBACL7C;gBACA8C,OAAOC,OAAO7C,IAAIoC,EAAE;YACtB;QACF;QAEAnC,IAAIO,OAAO,CAACgC,IAAI,CAACM,GAAG,GAAGhB,KAAK,CAAC,CAACiB;YAC5B9C,IAAIO,OAAO,CAACwC,MAAM,CAACC,KAAK,CAAC;gBAAEF;YAAI,GAAG;QACpC;QAEA,OAAO/C;IACT;AACF,EAAC"}
@@ -1,5 +1,6 @@
1
+ import path from 'path';
1
2
  import { resolveCollectionConfig } from '../defaults.js';
2
- import { generateThumbHash, stripAndResize } from '../processing/index.js';
3
+ import { convertFormat, generateThumbHash, stripAndResize } from '../processing/index.js';
3
4
  export const createBeforeChangeHook = (resolvedConfig, collectionSlug)=>{
4
5
  return async ({ context, data, req })=>{
5
6
  if (context?.imageOptimizer_skip) return data;
@@ -8,17 +9,33 @@ export const createBeforeChangeHook = (resolvedConfig, collectionSlug)=>{
8
9
  const perCollectionConfig = resolveCollectionConfig(resolvedConfig, collectionSlug);
9
10
  // Process in memory: strip EXIF, resize, generate blur
10
11
  const processed = await stripAndResize(req.file.data, perCollectionConfig.maxDimensions, resolvedConfig.stripMetadata);
12
+ let finalBuffer = processed.buffer;
13
+ let finalSize = processed.size;
14
+ if (perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0) {
15
+ // Convert to primary format (first in the formats array)
16
+ const primaryFormat = perCollectionConfig.formats[0];
17
+ const converted = await convertFormat(processed.buffer, primaryFormat.format, primaryFormat.quality);
18
+ finalBuffer = converted.buffer;
19
+ finalSize = converted.size;
20
+ // Update filename and mimeType so Payload stores the correct metadata
21
+ const originalFilename = data.filename || req.file.name || '';
22
+ const newFilename = `${path.parse(originalFilename).name}.${primaryFormat.format}`;
23
+ context.imageOptimizer_originalFilename = originalFilename;
24
+ data.filename = newFilename;
25
+ data.mimeType = converted.mimeType;
26
+ data.filesize = finalSize;
27
+ }
11
28
  data.imageOptimizer = {
12
29
  originalSize,
13
- optimizedSize: processed.size,
30
+ optimizedSize: finalSize,
14
31
  status: 'pending'
15
32
  };
16
33
  if (resolvedConfig.generateThumbHash) {
17
- data.imageOptimizer.thumbHash = await generateThumbHash(processed.buffer);
34
+ data.imageOptimizer.thumbHash = await generateThumbHash(finalBuffer);
18
35
  }
19
36
  // Store processed buffer in context for afterChange to write to disk
20
37
  // (Payload 3.0 does not use modified req.file.data for the disk write)
21
- context.imageOptimizer_processedBuffer = processed.buffer;
38
+ context.imageOptimizer_processedBuffer = finalBuffer;
22
39
  return data;
23
40
  };
24
41
  };
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/hooks/beforeChange.ts"],"sourcesContent":["import type { CollectionBeforeChangeHook } from 'payload'\n\nimport type { ResolvedImageOptimizerConfig } from '../types.js'\nimport { resolveCollectionConfig } from '../defaults.js'\nimport { 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 data.imageOptimizer = {\n originalSize,\n optimizedSize: processed.size,\n status: 'pending',\n }\n\n if (resolvedConfig.generateThumbHash) {\n data.imageOptimizer.thumbHash = await generateThumbHash(processed.buffer)\n }\n\n // Store processed buffer in context for afterChange to write to disk\n // (Payload 3.0 does not use modified req.file.data for the disk write)\n context.imageOptimizer_processedBuffer = processed.buffer\n\n return data\n }\n}\n"],"names":["resolveCollectionConfig","generateThumbHash","stripAndResize","createBeforeChangeHook","resolvedConfig","collectionSlug","context","data","req","imageOptimizer_skip","file","mimetype","startsWith","originalSize","length","perCollectionConfig","processed","maxDimensions","stripMetadata","imageOptimizer","optimizedSize","size","status","thumbHash","buffer","imageOptimizer_processedBuffer"],"mappings":"AAGA,SAASA,uBAAuB,QAAQ,iBAAgB;AACxD,SAASC,iBAAiB,EAAEC,cAAc,QAAQ,yBAAwB;AAE1E,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,sBAAsBf,wBAAwBI,gBAAgBC;QAEpE,uDAAuD;QACvD,MAAMW,YAAY,MAAMd,eACtBM,IAAIE,IAAI,CAACH,IAAI,EACbQ,oBAAoBE,aAAa,EACjCb,eAAec,aAAa;QAG9BX,KAAKY,cAAc,GAAG;YACpBN;YACAO,eAAeJ,UAAUK,IAAI;YAC7BC,QAAQ;QACV;QAEA,IAAIlB,eAAeH,iBAAiB,EAAE;YACpCM,KAAKY,cAAc,CAACI,SAAS,GAAG,MAAMtB,kBAAkBe,UAAUQ,MAAM;QAC1E;QAEA,qEAAqE;QACrE,uEAAuE;QACvElB,QAAQmB,8BAA8B,GAAGT,UAAUQ,MAAM;QAEzD,OAAOjB;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'\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 // Store processed buffer in context for afterChange to write to disk\n // (Payload 3.0 does not use modified req.file.data for the disk write)\n context.imageOptimizer_processedBuffer = finalBuffer\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"],"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,qEAAqE;QACrE,uEAAuE;QACvEb,QAAQmC,8BAA8B,GAAGtB;QAEzC,OAAOZ;IACT;AACF,EAAC"}
@@ -23,7 +23,10 @@ export const createConvertFormatsHandler = (resolvedConfig)=>{
23
23
  const fileBuffer = await fs.readFile(filePath);
24
24
  const variants = [];
25
25
  const perCollectionConfig = resolveCollectionConfig(resolvedConfig, input.collectionSlug);
26
- for (const format of perCollectionConfig.formats){
26
+ // When replaceOriginal is on, the main file is already in the primary format
27
+ // skip it and only generate variants for the remaining formats.
28
+ const formatsToGenerate = perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0 ? perCollectionConfig.formats.slice(1) : perCollectionConfig.formats;
29
+ for (const format of formatsToGenerate){
27
30
  const result = await convertFormat(fileBuffer, format.format, format.quality);
28
31
  const variantFilename = `${path.parse(safeFilename).name}-optimized.${format.format}`;
29
32
  await fs.writeFile(path.join(staticDir, variantFilename), result.buffer);
@@ -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'\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\n let staticDir: string =\n typeof collectionConfig.upload === 'object' ? collectionConfig.upload.staticDir || '' : ''\n if (!staticDir) {\n throw new Error(`No staticDir configured for collection \"${input.collectionSlug}\"`)\n }\n if (!path.isAbsolute(staticDir)) {\n staticDir = path.resolve(process.cwd(), staticDir)\n }\n\n // Sanitize filename to prevent path traversal\n const safeFilename = path.basename(doc.filename)\n const filePath = path.join(staticDir, safeFilename)\n const fileBuffer = await fs.readFile(filePath)\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 for (const format of perCollectionConfig.formats) {\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 status: 'complete',\n variants,\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","createConvertFormatsHandler","resolvedConfig","input","req","doc","payload","findByID","collection","collectionSlug","id","docId","collectionConfig","collections","config","staticDir","upload","Error","isAbsolute","resolve","process","cwd","safeFilename","basename","filename","filePath","join","fileBuffer","readFile","variants","perCollectionConfig","format","formats","result","quality","variantFilename","parse","name","writeFile","buffer","push","filesize","size","width","height","mimeType","url","update","data","imageOptimizer","status","context","imageOptimizer_skip","output","variantsGenerated","length","err","errorMessage","message","String","error","updateErr","logger"],"mappings":"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"}
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'\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\n let staticDir: string =\n typeof collectionConfig.upload === 'object' ? collectionConfig.upload.staticDir || '' : ''\n if (!staticDir) {\n throw new Error(`No staticDir configured for collection \"${input.collectionSlug}\"`)\n }\n if (!path.isAbsolute(staticDir)) {\n staticDir = path.resolve(process.cwd(), staticDir)\n }\n\n // Sanitize filename to prevent path traversal\n const safeFilename = path.basename(doc.filename)\n const filePath = path.join(staticDir, safeFilename)\n const fileBuffer = await fs.readFile(filePath)\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 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 status: 'complete',\n variants,\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","createConvertFormatsHandler","resolvedConfig","input","req","doc","payload","findByID","collection","collectionSlug","id","docId","collectionConfig","collections","config","staticDir","upload","Error","isAbsolute","resolve","process","cwd","safeFilename","basename","filename","filePath","join","fileBuffer","readFile","variants","perCollectionConfig","formatsToGenerate","replaceOriginal","formats","length","slice","format","result","quality","variantFilename","parse","name","writeFile","buffer","push","filesize","size","width","height","mimeType","url","update","data","imageOptimizer","status","context","imageOptimizer_skip","output","variantsGenerated","err","errorMessage","message","String","error","updateErr","logger"],"mappings":"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"}
@@ -46,18 +46,37 @@ export const createRegenerateDocumentHandler = (resolvedConfig)=>{
46
46
  const perCollectionConfig = resolveCollectionConfig(resolvedConfig, input.collectionSlug);
47
47
  // Step 1: Strip metadata + resize
48
48
  const processed = await stripAndResize(fileBuffer, perCollectionConfig.maxDimensions, resolvedConfig.stripMetadata);
49
- // Write optimized file back to disk
50
- await fs.writeFile(filePath, processed.buffer);
49
+ let mainBuffer = processed.buffer;
50
+ let mainSize = processed.size;
51
+ let newFilename = safeFilename;
52
+ let newMimeType;
53
+ // Step 1b: If replaceOriginal, convert main file to primary format
54
+ if (perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0) {
55
+ const primaryFormat = perCollectionConfig.formats[0];
56
+ const converted = await convertFormat(processed.buffer, primaryFormat.format, primaryFormat.quality);
57
+ mainBuffer = converted.buffer;
58
+ mainSize = converted.size;
59
+ newFilename = `${path.parse(safeFilename).name}.${primaryFormat.format}`;
60
+ newMimeType = converted.mimeType;
61
+ }
62
+ // Write optimized file to disk
63
+ const newFilePath = path.join(staticDir, newFilename);
64
+ await fs.writeFile(newFilePath, mainBuffer);
65
+ // Clean up old file if filename changed
66
+ if (newFilename !== safeFilename) {
67
+ await fs.unlink(filePath).catch(()=>{});
68
+ }
51
69
  // Step 2: Generate ThumbHash
52
70
  let thumbHash;
53
71
  if (resolvedConfig.generateThumbHash) {
54
- thumbHash = await generateThumbHash(processed.buffer);
72
+ thumbHash = await generateThumbHash(mainBuffer);
55
73
  }
56
- // Step 3: Convert to all configured formats
74
+ // Step 3: Convert to configured formats (skip primary when replaceOriginal)
75
+ const formatsToGenerate = perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0 ? perCollectionConfig.formats.slice(1) : perCollectionConfig.formats;
57
76
  const variants = [];
58
- for (const format of perCollectionConfig.formats){
59
- const result = await convertFormat(processed.buffer, format.format, format.quality);
60
- const variantFilename = `${path.parse(safeFilename).name}-optimized.${format.format}`;
77
+ for (const format of formatsToGenerate){
78
+ const result = await convertFormat(mainBuffer, format.format, format.quality);
79
+ const variantFilename = `${path.parse(newFilename).name}-optimized.${format.format}`;
61
80
  await fs.writeFile(path.join(staticDir, variantFilename), result.buffer);
62
81
  variants.push({
63
82
  format: format.format,
@@ -70,19 +89,26 @@ export const createRegenerateDocumentHandler = (resolvedConfig)=>{
70
89
  });
71
90
  }
72
91
  // Step 4: Update the document with all optimization data
92
+ const updateData = {
93
+ imageOptimizer: {
94
+ originalSize,
95
+ optimizedSize: mainSize,
96
+ status: 'complete',
97
+ thumbHash,
98
+ variants,
99
+ error: null
100
+ }
101
+ };
102
+ // Update filename, mimeType, and filesize when replaceOriginal changed them
103
+ if (newFilename !== safeFilename) {
104
+ updateData.filename = newFilename;
105
+ updateData.filesize = mainSize;
106
+ updateData.mimeType = newMimeType;
107
+ }
73
108
  await req.payload.update({
74
109
  collection: input.collectionSlug,
75
110
  id: input.docId,
76
- data: {
77
- imageOptimizer: {
78
- originalSize,
79
- optimizedSize: processed.size,
80
- status: 'complete',
81
- thumbHash,
82
- variants,
83
- error: null
84
- }
85
- },
111
+ data: updateData,
86
112
  context: {
87
113
  imageOptimizer_skip: true
88
114
  }
@@ -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'\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\n let staticDir: string =\n typeof collectionConfig.upload === 'object' ? collectionConfig.upload.staticDir || '' : ''\n if (!staticDir) {\n throw new Error(`No staticDir configured for collection \"${input.collectionSlug}\"`)\n }\n if (!path.isAbsolute(staticDir)) {\n staticDir = path.resolve(process.cwd(), staticDir)\n }\n\n // Sanitize filename to prevent path traversal\n const safeFilename = path.basename(doc.filename)\n const filePath = path.join(staticDir, safeFilename)\n\n let fileBuffer: Buffer\n try {\n fileBuffer = await fs.readFile(filePath)\n } catch {\n // If file not on disk, try fetching from URL\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 fileBuffer = Buffer.from(await response.arrayBuffer())\n } else {\n throw new Error(`File not found: ${filePath}`)\n }\n }\n\n const originalSize = fileBuffer.length\n const perCollectionConfig = resolveCollectionConfig(resolvedConfig, input.collectionSlug)\n\n // Step 1: Strip metadata + resize\n const processed = await stripAndResize(\n fileBuffer,\n perCollectionConfig.maxDimensions,\n resolvedConfig.stripMetadata,\n )\n\n // Write optimized file back to disk\n await fs.writeFile(filePath, processed.buffer)\n\n // Step 2: Generate ThumbHash\n let thumbHash: string | undefined\n if (resolvedConfig.generateThumbHash) {\n thumbHash = await generateThumbHash(processed.buffer)\n }\n\n // Step 3: Convert to all configured formats\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 for (const format of perCollectionConfig.formats) {\n const result = await convertFormat(processed.buffer, format.format, format.quality)\n const variantFilename = `${path.parse(safeFilename).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 // Step 4: Update the document with all optimization data\n await req.payload.update({\n collection: input.collectionSlug as CollectionSlug,\n id: input.docId,\n data: {\n imageOptimizer: {\n originalSize,\n optimizedSize: processed.size,\n status: 'complete',\n thumbHash,\n variants,\n error: null,\n },\n },\n context: { imageOptimizer_skip: true },\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","createRegenerateDocumentHandler","resolvedConfig","input","req","doc","payload","findByID","collection","collectionSlug","id","docId","mimeType","startsWith","output","status","reason","collectionConfig","collections","config","staticDir","upload","Error","isAbsolute","resolve","process","cwd","safeFilename","basename","filename","filePath","join","fileBuffer","readFile","url","env","NEXT_PUBLIC_SERVER_URL","response","fetch","Buffer","from","arrayBuffer","originalSize","length","perCollectionConfig","processed","maxDimensions","stripMetadata","writeFile","buffer","thumbHash","variants","format","formats","result","quality","variantFilename","parse","name","push","filesize","size","width","height","update","data","imageOptimizer","optimizedSize","error","context","imageOptimizer_skip","err","errorMessage","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'\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\n let staticDir: string =\n typeof collectionConfig.upload === 'object' ? collectionConfig.upload.staticDir || '' : ''\n if (!staticDir) {\n throw new Error(`No staticDir configured for collection \"${input.collectionSlug}\"`)\n }\n if (!path.isAbsolute(staticDir)) {\n staticDir = path.resolve(process.cwd(), staticDir)\n }\n\n // Sanitize filename to prevent path traversal\n const safeFilename = path.basename(doc.filename)\n const filePath = path.join(staticDir, safeFilename)\n\n let fileBuffer: Buffer\n try {\n fileBuffer = await fs.readFile(filePath)\n } catch {\n // If file not on disk, try fetching from URL\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 fileBuffer = Buffer.from(await response.arrayBuffer())\n } else {\n throw new Error(`File not found: ${filePath}`)\n }\n }\n\n const originalSize = fileBuffer.length\n const perCollectionConfig = resolveCollectionConfig(resolvedConfig, input.collectionSlug)\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 // Write optimized file to disk\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 await fs.unlink(filePath).catch(() => {})\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: Convert to configured formats (skip primary when replaceOriginal)\n const formatsToGenerate = perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0\n ? perCollectionConfig.formats.slice(1)\n : perCollectionConfig.formats\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 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 // Step 4: Update the document with all 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 // Update filename, mimeType, and filesize when replaceOriginal changed them\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 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","createRegenerateDocumentHandler","resolvedConfig","input","req","doc","payload","findByID","collection","collectionSlug","id","docId","mimeType","startsWith","output","status","reason","collectionConfig","collections","config","staticDir","upload","Error","isAbsolute","resolve","process","cwd","safeFilename","basename","filename","filePath","join","fileBuffer","readFile","url","env","NEXT_PUBLIC_SERVER_URL","response","fetch","Buffer","from","arrayBuffer","originalSize","length","perCollectionConfig","processed","maxDimensions","stripMetadata","mainBuffer","buffer","mainSize","size","newFilename","newMimeType","replaceOriginal","formats","primaryFormat","converted","format","quality","parse","name","newFilePath","writeFile","unlink","catch","thumbHash","formatsToGenerate","slice","variants","result","variantFilename","push","filesize","width","height","updateData","imageOptimizer","optimizedSize","error","update","data","context","imageOptimizer_skip","err","errorMessage","message","String","updateErr","logger"],"mappings":"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"}
package/dist/types.d.ts CHANGED
@@ -10,6 +10,7 @@ export type CollectionOptimizerConfig = {
10
10
  width: number;
11
11
  height: number;
12
12
  };
13
+ replaceOriginal?: boolean;
13
14
  };
14
15
  export type ImageOptimizerConfig = {
15
16
  collections: Partial<Record<CollectionSlug, true | CollectionOptimizerConfig>>;
@@ -20,6 +21,7 @@ export type ImageOptimizerConfig = {
20
21
  width: number;
21
22
  height: number;
22
23
  };
24
+ replaceOriginal?: boolean;
23
25
  stripMetadata?: boolean;
24
26
  };
25
27
  export type ResolvedCollectionOptimizerConfig = {
@@ -28,8 +30,10 @@ export type ResolvedCollectionOptimizerConfig = {
28
30
  width: number;
29
31
  height: number;
30
32
  };
33
+ replaceOriginal: boolean;
31
34
  };
32
35
  export type ResolvedImageOptimizerConfig = Required<Pick<ImageOptimizerConfig, 'formats' | 'generateThumbHash' | 'maxDimensions' | 'stripMetadata'>> & {
33
36
  collections: ImageOptimizerConfig['collections'];
34
37
  disabled: boolean;
38
+ replaceOriginal: boolean;
35
39
  };
package/dist/types.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/types.ts"],"sourcesContent":["import type { CollectionSlug } from 'payload'\n\nexport type ImageFormat = 'webp' | 'avif'\n\nexport type FormatQuality = {\n format: ImageFormat\n quality: number // 1-100\n}\n\nexport type CollectionOptimizerConfig = {\n formats?: FormatQuality[]\n maxDimensions?: { width: number; height: number }\n}\n\nexport type ImageOptimizerConfig = {\n collections: Partial<Record<CollectionSlug, true | CollectionOptimizerConfig>>\n disabled?: boolean\n formats?: FormatQuality[]\n generateThumbHash?: boolean\n maxDimensions?: { width: number; height: number }\n stripMetadata?: boolean\n}\n\nexport type ResolvedCollectionOptimizerConfig = {\n formats: FormatQuality[]\n maxDimensions: { width: number; height: number }\n}\n\nexport type ResolvedImageOptimizerConfig = Required<\n Pick<ImageOptimizerConfig, 'formats' | 'generateThumbHash' | 'maxDimensions' | 'stripMetadata'>\n> & {\n collections: ImageOptimizerConfig['collections']\n disabled: boolean\n}\n"],"names":[],"mappings":"AA4BA,WAKC"}
1
+ {"version":3,"sources":["../src/types.ts"],"sourcesContent":["import type { CollectionSlug } from 'payload'\n\nexport type ImageFormat = 'webp' | 'avif'\n\nexport type FormatQuality = {\n format: ImageFormat\n quality: number // 1-100\n}\n\nexport type CollectionOptimizerConfig = {\n formats?: FormatQuality[]\n maxDimensions?: { width: number; height: number }\n replaceOriginal?: boolean\n}\n\nexport type ImageOptimizerConfig = {\n collections: Partial<Record<CollectionSlug, true | CollectionOptimizerConfig>>\n disabled?: boolean\n formats?: FormatQuality[]\n generateThumbHash?: boolean\n maxDimensions?: { width: number; height: number }\n replaceOriginal?: boolean\n stripMetadata?: boolean\n}\n\nexport type ResolvedCollectionOptimizerConfig = {\n formats: FormatQuality[]\n maxDimensions: { width: number; height: number }\n replaceOriginal: boolean\n}\n\nexport type ResolvedImageOptimizerConfig = Required<\n Pick<ImageOptimizerConfig, 'formats' | 'generateThumbHash' | 'maxDimensions' | 'stripMetadata'>\n> & {\n collections: ImageOptimizerConfig['collections']\n disabled: boolean\n replaceOriginal: boolean\n}\n"],"names":[],"mappings":"AA+BA,WAMC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inoo-ch/payload-image-optimizer",
3
- "version": "1.0.0",
3
+ "version": "1.1.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": [