@inoo-ch/payload-image-optimizer 1.5.1 → 1.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENT_DOCS.md +207 -38
- package/README.md +80 -19
- package/dist/components/ImageBox.js +8 -3
- package/dist/components/ImageBox.js.map +1 -1
- package/dist/defaults.js +2 -1
- package/dist/defaults.js.map +1 -1
- package/dist/exports/client.d.ts +3 -0
- package/dist/exports/client.js +2 -0
- package/dist/exports/client.js.map +1 -1
- package/dist/hooks/beforeChange.js +10 -0
- package/dist/hooks/beforeChange.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js.map +1 -1
- package/dist/tasks/regenerateDocument.js +7 -0
- package/dist/tasks/regenerateDocument.js.map +1 -1
- package/dist/types.d.ts +14 -0
- package/dist/types.js.map +1 -1
- package/dist/utilities/getOptimizedImageProps.d.ts +44 -0
- package/dist/utilities/getOptimizedImageProps.js +43 -0
- package/dist/utilities/getOptimizedImageProps.js.map +1 -0
- package/dist/utilities/responsiveImage.d.ts +46 -0
- package/dist/utilities/responsiveImage.js +64 -0
- package/dist/utilities/responsiveImage.js.map +1 -0
- package/package.json +1 -1
- package/src/components/ImageBox.tsx +6 -3
- package/src/defaults.ts +1 -0
- package/src/exports/client.ts +3 -0
- package/src/hooks/beforeChange.ts +11 -0
- package/src/index.ts +1 -1
- package/src/tasks/regenerateDocument.ts +8 -0
- package/src/types.ts +15 -0
- package/src/utilities/getOptimizedImageProps.ts +53 -0
- package/src/utilities/responsiveImage.ts +91 -0
package/AGENT_DOCS.md
CHANGED
|
@@ -60,6 +60,7 @@ imageOptimizer({
|
|
|
60
60
|
generateThumbHash: true, // generate blur placeholders
|
|
61
61
|
replaceOriginal: true, // convert main file to primary format
|
|
62
62
|
clientOptimization: true, // pre-resize in browser before upload
|
|
63
|
+
uniqueFileNames: false, // replace filenames with UUIDs
|
|
63
64
|
disabled: false, // keep fields but skip all processing
|
|
64
65
|
})
|
|
65
66
|
```
|
|
@@ -70,6 +71,7 @@ imageOptimizer({
|
|
|
70
71
|
| `formats` | `{ format: 'webp' \| 'avif', quality: number }[]` | `[{ format: 'webp', quality: 80 }]` | Output formats to generate. |
|
|
71
72
|
| `maxDimensions` | `{ width: number, height: number }` | `{ width: 2560, height: 2560 }` | Maximum image dimensions (fit inside, no upscaling). |
|
|
72
73
|
| `stripMetadata` | `boolean` | `true` | Strip EXIF, ICC, XMP metadata. |
|
|
74
|
+
| `uniqueFileNames` | `boolean` | `false` | Replace filenames with UUIDs. Prevents Vercel Blob collisions and hides original filenames. |
|
|
73
75
|
| `generateThumbHash` | `boolean` | `true` | Generate ThumbHash blur placeholder. |
|
|
74
76
|
| `replaceOriginal` | `boolean` | `true` | Replace the original file with the primary format. |
|
|
75
77
|
| `clientOptimization` | `boolean` | `true` | Pre-resize images in browser via Canvas API before upload. Reduces upload size 90%+ for large images. |
|
|
@@ -97,6 +99,7 @@ collections: {
|
|
|
97
99
|
When an image is uploaded to an optimized collection:
|
|
98
100
|
|
|
99
101
|
1. **`beforeChange` hook** (in-memory processing):
|
|
102
|
+
- If `uniqueFileNames: true`: renames file to UUID (e.g., `photo.jpg` → `a1b2c3d4.jpg`)
|
|
100
103
|
- Auto-rotates based on EXIF orientation
|
|
101
104
|
- Resizes to fit within `maxDimensions`
|
|
102
105
|
- Strips metadata (if enabled)
|
|
@@ -119,6 +122,7 @@ When an image is uploaded to an optimized collection:
|
|
|
119
122
|
| File | Naming Pattern | Example |
|
|
120
123
|
|------|---------------|---------|
|
|
121
124
|
| Main file (replaceOriginal) | `{name}.{primaryFormat}` | `photo.webp` |
|
|
125
|
+
| Main file (uniqueFileNames) | `{uuid}.{primaryFormat}` | `a1b2c3d4-5e6f-7890.webp` |
|
|
122
126
|
| Variant files | `{name}-optimized.{format}` | `photo-optimized.avif` |
|
|
123
127
|
|
|
124
128
|
### Format Behavior
|
|
@@ -219,24 +223,24 @@ Get current optimization status for a collection. Requires authentication.
|
|
|
219
223
|
|
|
220
224
|
Import from `@inoo-ch/payload-image-optimizer/client`:
|
|
221
225
|
|
|
222
|
-
### `ImageBox` Component
|
|
226
|
+
### `ImageBox` Component (Recommended)
|
|
223
227
|
|
|
224
|
-
Drop-in Next.js `<Image>` wrapper with
|
|
228
|
+
Drop-in Next.js `<Image>` wrapper — the easiest way to display images with best practices. Automatically handles ThumbHash blur placeholders, focal point positioning, smooth fade-in, responsive variant loading, and smart `sizes` defaults.
|
|
225
229
|
|
|
226
230
|
```tsx
|
|
227
231
|
import { ImageBox } from '@inoo-ch/payload-image-optimizer/client'
|
|
228
232
|
|
|
229
|
-
//
|
|
230
|
-
<ImageBox media={doc.
|
|
233
|
+
// Pass the full Payload media document — ImageBox handles everything
|
|
234
|
+
<ImageBox media={doc.heroImage} alt="Hero" fill priority />
|
|
231
235
|
|
|
232
|
-
//
|
|
233
|
-
<ImageBox media=
|
|
236
|
+
// Card grid — explicit sizes hint for responsive loading
|
|
237
|
+
<ImageBox media={doc.image} alt="Card" fill sizes="(max-width: 768px) 100vw, 33vw" />
|
|
234
238
|
|
|
235
|
-
//
|
|
236
|
-
<ImageBox media={doc.
|
|
239
|
+
// Fixed dimensions (non-fill)
|
|
240
|
+
<ImageBox media={doc.avatar} alt="Avatar" width={64} height={64} fade={false} />
|
|
237
241
|
|
|
238
|
-
//
|
|
239
|
-
<ImageBox media=
|
|
242
|
+
// Plain URL string fallback
|
|
243
|
+
<ImageBox media="/images/fallback.jpg" alt="Fallback" width={800} height={600} />
|
|
240
244
|
```
|
|
241
245
|
|
|
242
246
|
**Props:** Extends all Next.js `ImageProps` (except `src`), plus:
|
|
@@ -248,45 +252,92 @@ import { ImageBox } from '@inoo-ch/payload-image-optimizer/client'
|
|
|
248
252
|
| `fade` | `boolean` | `true` | Enable smooth blur-to-sharp fade transition on load |
|
|
249
253
|
| `fadeDuration` | `number` | `500` | Duration of the fade animation in milliseconds |
|
|
250
254
|
|
|
251
|
-
|
|
252
|
-
- ThumbHash blur placeholder (if available on the media resource)
|
|
253
|
-
- Smooth blur-to-sharp fade transition on image load (disable with `fade={false}`)
|
|
254
|
-
- Focal point positioning via `objectPosition` (using `focalX`/`focalY`)
|
|
255
|
-
- Cache-busting via `updatedAt` query parameter
|
|
256
|
-
- `objectFit: 'cover'` by default (overridable via `style`)
|
|
255
|
+
**What ImageBox does automatically:**
|
|
257
256
|
|
|
258
|
-
|
|
257
|
+
- **ThumbHash blur placeholder** — per-image blur preview from `imageOptimizer.thumbHash`
|
|
258
|
+
- **Responsive variant loader** — when `media.sizes` has Payload size variants (from `imageSizes` collection config), serves pre-generated variants directly instead of going through `/_next/image` re-optimization. Falls back to `/_next/image` when no close match exists.
|
|
259
|
+
- **Smart `sizes` default** — for `fill` mode without explicit `sizes`, uses `(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw` instead of the browser's `100vw` assumption
|
|
260
|
+
- **Focal point positioning** — applies `objectPosition` from `focalX`/`focalY`
|
|
261
|
+
- **Fade transition** — smooth blur-to-sharp animation on load
|
|
262
|
+
- **Cache busting** — appends `updatedAt` as query parameter
|
|
259
263
|
|
|
260
|
-
|
|
264
|
+
**Important:** For the variant loader to work, your collection must have `imageSizes` configured in the Payload collection config. This is how Payload generates the width variants. Without `imageSizes`, images still work but go through `/_next/image` as usual.
|
|
265
|
+
|
|
266
|
+
### `getOptimizedImageProps()` — For Existing Components (e.g., Payload Website Template)
|
|
267
|
+
|
|
268
|
+
Single-function integration for existing `<NextImage>` components. Returns ThumbHash, focal point, AND variant loader in one spread-friendly object. **This is the recommended way to integrate with the Payload website template's `ImageMedia` component.**
|
|
261
269
|
|
|
262
270
|
```tsx
|
|
263
|
-
import {
|
|
271
|
+
import { getOptimizedImageProps } from '@inoo-ch/payload-image-optimizer/client'
|
|
264
272
|
|
|
265
|
-
|
|
273
|
+
// In your existing ImageMedia component — 3 lines to add:
|
|
274
|
+
const optimizedProps = getOptimizedImageProps(resource)
|
|
266
275
|
|
|
267
|
-
<
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
276
|
+
<NextImage
|
|
277
|
+
{...optimizedProps} // spreads: placeholder, blurDataURL, style, loader
|
|
278
|
+
src={src}
|
|
279
|
+
alt={alt}
|
|
280
|
+
fill={fill}
|
|
281
|
+
sizes={sizes}
|
|
282
|
+
quality={80}
|
|
283
|
+
priority={priority}
|
|
284
|
+
loading={loading}
|
|
273
285
|
/>
|
|
274
286
|
```
|
|
275
287
|
|
|
276
|
-
**
|
|
288
|
+
**Returns:**
|
|
289
|
+
```ts
|
|
290
|
+
{
|
|
291
|
+
placeholder: 'blur' | 'empty',
|
|
292
|
+
blurDataURL?: string, // data URL from ThumbHash
|
|
293
|
+
style: { objectPosition: string }, // from focalX/focalY
|
|
294
|
+
loader?: ImageLoader, // variant-aware loader (only when media.sizes has variants)
|
|
295
|
+
}
|
|
296
|
+
```
|
|
277
297
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
298
|
+
**Payload Website Template integration example:**
|
|
299
|
+
|
|
300
|
+
If you're using the [Payload website template](https://github.com/payloadcms/payload/tree/main/templates/website), modify `src/components/Media/ImageMedia/index.tsx`:
|
|
301
|
+
|
|
302
|
+
```diff
|
|
303
|
+
+ import { getOptimizedImageProps } from '@inoo-ch/payload-image-optimizer/client'
|
|
304
|
+
|
|
305
|
+
export const ImageMedia: React.FC<MediaProps> = (props) => {
|
|
306
|
+
// ... existing code ...
|
|
307
|
+
|
|
308
|
+
+ const optimizedProps = typeof resource === 'object' ? getOptimizedImageProps(resource) : {}
|
|
309
|
+
|
|
310
|
+
return (
|
|
311
|
+
<picture className={cn(pictureClassName)}>
|
|
312
|
+
<NextImage
|
|
313
|
+
+ {...optimizedProps}
|
|
314
|
+
alt={alt || ''}
|
|
315
|
+
className={cn(imgClassName)}
|
|
316
|
+
fill={fill}
|
|
317
|
+
height={!fill ? height : undefined}
|
|
318
|
+
- placeholder="blur"
|
|
319
|
+
- blurDataURL={placeholderBlur}
|
|
320
|
+
priority={priority}
|
|
321
|
+
- quality={100}
|
|
322
|
+
+ quality={80}
|
|
323
|
+
loading={loading}
|
|
324
|
+
sizes={sizes}
|
|
325
|
+
src={src}
|
|
326
|
+
width={!fill ? width : undefined}
|
|
327
|
+
/>
|
|
328
|
+
</picture>
|
|
329
|
+
)
|
|
330
|
+
}
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
This replaces the template's hardcoded blur placeholder with per-image ThumbHash, adds focal point support, and enables variant-aware responsive loading — all in a few lines.
|
|
282
334
|
|
|
283
|
-
### `getImageOptimizerProps()` Utility
|
|
335
|
+
### `getImageOptimizerProps()` — Low-Level Utility
|
|
284
336
|
|
|
285
|
-
|
|
337
|
+
Returns only ThumbHash placeholder and focal point props (no variant loader). Use when you need granular control or don't want the variant loader.
|
|
286
338
|
|
|
287
339
|
```tsx
|
|
288
340
|
import { getImageOptimizerProps } from '@inoo-ch/payload-image-optimizer/client'
|
|
289
|
-
import NextImage from 'next/image'
|
|
290
341
|
|
|
291
342
|
const optimizerProps = getImageOptimizerProps(media)
|
|
292
343
|
|
|
@@ -301,13 +352,76 @@ const optimizerProps = getImageOptimizerProps(media)
|
|
|
301
352
|
```ts
|
|
302
353
|
{
|
|
303
354
|
placeholder: 'blur' | 'empty',
|
|
304
|
-
blurDataURL?: string,
|
|
305
|
-
style: {
|
|
306
|
-
objectPosition: string, // e.g. '50% 30%' from focalX/focalY, or 'center'
|
|
307
|
-
},
|
|
355
|
+
blurDataURL?: string,
|
|
356
|
+
style: { objectPosition: string },
|
|
308
357
|
}
|
|
309
358
|
```
|
|
310
359
|
|
|
360
|
+
### `createVariantLoader()` — Custom Loader Factory
|
|
361
|
+
|
|
362
|
+
Creates a Next.js Image `loader` that maps requested widths to pre-generated Payload size variants. Use when building fully custom image components.
|
|
363
|
+
|
|
364
|
+
```tsx
|
|
365
|
+
import { createVariantLoader } from '@inoo-ch/payload-image-optimizer/client'
|
|
366
|
+
|
|
367
|
+
const loader = createVariantLoader(media) // returns undefined when no variants
|
|
368
|
+
|
|
369
|
+
<NextImage loader={loader} src={media.url} sizes="100vw" ... />
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
**How the hybrid loader works:**
|
|
373
|
+
1. Finds the smallest Payload size variant with width >= requested width
|
|
374
|
+
2. If found → serves the pre-generated variant URL directly (bypasses `/_next/image`)
|
|
375
|
+
3. If no variant is large enough → uses the largest variant if it covers >= 80% of requested width
|
|
376
|
+
4. If no close match at all → falls back to `/_next/image` re-optimization
|
|
377
|
+
|
|
378
|
+
### `getDefaultSizes()` — Smart Sizes Helper
|
|
379
|
+
|
|
380
|
+
Returns a sensible default `sizes` attribute for fill-mode images:
|
|
381
|
+
|
|
382
|
+
```tsx
|
|
383
|
+
import { getDefaultSizes } from '@inoo-ch/payload-image-optimizer/client'
|
|
384
|
+
|
|
385
|
+
const sizes = getDefaultSizes(true) // '(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw'
|
|
386
|
+
const sizes = getDefaultSizes(false) // undefined (let next/image use 1x/2x descriptors)
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
### `FadeImage` Component
|
|
390
|
+
|
|
391
|
+
Standalone Next.js `<Image>` wrapper with fade-in transition for use with `getImageOptimizerProps()`. Use this when you have a custom image component and want the fade effect without `ImageBox`.
|
|
392
|
+
|
|
393
|
+
```tsx
|
|
394
|
+
import { FadeImage, getImageOptimizerProps } from '@inoo-ch/payload-image-optimizer/client'
|
|
395
|
+
|
|
396
|
+
const optimizerProps = getImageOptimizerProps(resource)
|
|
397
|
+
|
|
398
|
+
<FadeImage
|
|
399
|
+
src={resource.url}
|
|
400
|
+
alt=""
|
|
401
|
+
width={800}
|
|
402
|
+
height={600}
|
|
403
|
+
optimizerProps={optimizerProps}
|
|
404
|
+
/>
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
**Props:** Extends all Next.js `ImageProps` (except `placeholder`, `blurDataURL`, `onLoad`), plus:
|
|
408
|
+
|
|
409
|
+
| Prop | Type | Default | Description |
|
|
410
|
+
|------|------|---------|-------------|
|
|
411
|
+
| `optimizerProps` | `ImageOptimizerProps` | — | Props returned by `getImageOptimizerProps()` |
|
|
412
|
+
| `fadeDuration` | `number` | `500` | Duration of the fade animation in milliseconds |
|
|
413
|
+
|
|
414
|
+
### Client Utility Decision Guide
|
|
415
|
+
|
|
416
|
+
| Scenario | Use | Why |
|
|
417
|
+
|----------|-----|-----|
|
|
418
|
+
| **New project, fresh components** | `ImageBox` | Zero-config, handles everything |
|
|
419
|
+
| **Existing project with Payload website template** | `getOptimizedImageProps()` | 3-line change to existing `ImageMedia` |
|
|
420
|
+
| **Custom component, want blur + focal + variants** | `getOptimizedImageProps()` | Single spread, all features |
|
|
421
|
+
| **Custom component, only want blur + focal** | `getImageOptimizerProps()` | Lighter, no loader |
|
|
422
|
+
| **Fully custom loader logic** | `createVariantLoader()` | Granular control |
|
|
423
|
+
| **Custom component with fade animation** | `FadeImage` + `getImageOptimizerProps()` | Fade without ImageBox |
|
|
424
|
+
|
|
311
425
|
## Server-Side Utilities
|
|
312
426
|
|
|
313
427
|
Import from `@inoo-ch/payload-image-optimizer`:
|
|
@@ -352,6 +466,30 @@ vercelBlobStorage({
|
|
|
352
466
|
})
|
|
353
467
|
```
|
|
354
468
|
|
|
469
|
+
### "This blob already exists" error
|
|
470
|
+
|
|
471
|
+
When `replaceOriginal: true` (default), the plugin changes filenames (e.g., `photo.jpg` → `photo.webp`). If a blob with that name already exists, Vercel Blob throws an error because `@payloadcms/storage-vercel-blob` does not pass `allowOverwrite` to the Vercel Blob SDK.
|
|
472
|
+
|
|
473
|
+
**Fix (recommended):** Enable `uniqueFileNames` in the plugin config — replaces filenames with UUIDs before the storage adapter sees them. This fixes both initial uploads AND regeneration (the regeneration task also generates a new UUID for cloud storage re-uploads):
|
|
474
|
+
|
|
475
|
+
```ts
|
|
476
|
+
imageOptimizer({
|
|
477
|
+
collections: { media: true },
|
|
478
|
+
uniqueFileNames: true, // photo.jpg → a1b2c3d4-5e6f-7890-abcd-ef1234567890.webp
|
|
479
|
+
})
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
**Alternative:** Set `addRandomSuffix: true` on the storage adapter (only fixes initial uploads, not regeneration):
|
|
483
|
+
|
|
484
|
+
```ts
|
|
485
|
+
vercelBlobStorage({
|
|
486
|
+
collections: { media: true },
|
|
487
|
+
token: process.env.BLOB_READ_WRITE_TOKEN,
|
|
488
|
+
clientUploads: true,
|
|
489
|
+
addRandomSuffix: true,
|
|
490
|
+
})
|
|
491
|
+
```
|
|
492
|
+
|
|
355
493
|
## Full Example
|
|
356
494
|
|
|
357
495
|
```ts
|
|
@@ -415,15 +553,46 @@ import type {
|
|
|
415
553
|
CollectionOptimizerConfig,
|
|
416
554
|
FormatQuality,
|
|
417
555
|
ImageFormat, // 'webp' | 'avif'
|
|
556
|
+
MediaResource, // type for media documents passed to client utilities
|
|
557
|
+
MediaSizeVariant, // type for individual size variants in media.sizes
|
|
418
558
|
} from '@inoo-ch/payload-image-optimizer'
|
|
419
559
|
|
|
420
560
|
import type {
|
|
421
561
|
ImageBoxProps,
|
|
422
562
|
FadeImageProps,
|
|
423
563
|
ImageOptimizerProps, // return type of getImageOptimizerProps
|
|
564
|
+
OptimizedImageProps, // return type of getOptimizedImageProps
|
|
424
565
|
} from '@inoo-ch/payload-image-optimizer/client'
|
|
425
566
|
```
|
|
426
567
|
|
|
568
|
+
### `MediaResource` Type
|
|
569
|
+
|
|
570
|
+
The `MediaResource` type represents a Payload media document as consumed by client utilities. It accepts the full Payload media response including the `sizes` field (generated by Payload when `imageSizes` is configured on the collection).
|
|
571
|
+
|
|
572
|
+
```ts
|
|
573
|
+
type MediaResource = {
|
|
574
|
+
url?: string | null
|
|
575
|
+
alt?: string | null
|
|
576
|
+
width?: number | null
|
|
577
|
+
height?: number | null
|
|
578
|
+
filename?: string | null
|
|
579
|
+
focalX?: number | null
|
|
580
|
+
focalY?: number | null
|
|
581
|
+
imageOptimizer?: { thumbHash?: string | null } | null
|
|
582
|
+
updatedAt?: string
|
|
583
|
+
sizes?: Record<string, {
|
|
584
|
+
url?: string | null
|
|
585
|
+
width?: number | null
|
|
586
|
+
height?: number | null
|
|
587
|
+
mimeType?: string | null
|
|
588
|
+
filesize?: number | null
|
|
589
|
+
filename?: string | null
|
|
590
|
+
} | undefined>
|
|
591
|
+
}
|
|
592
|
+
```
|
|
593
|
+
|
|
594
|
+
The type is intentionally loose — it structurally matches any Payload media document whether or not the plugin is installed. You can pass your generated Payload types directly without casting.
|
|
595
|
+
|
|
427
596
|
## Context Flags
|
|
428
597
|
|
|
429
598
|
The plugin uses `req.context` flags to control processing:
|
package/README.md
CHANGED
|
@@ -17,7 +17,9 @@ Built and maintained by [inoo.ch](https://inoo.ch) — a Swiss digital agency cr
|
|
|
17
17
|
- **Bulk regeneration** — Re-process existing images from the admin UI with progress tracking
|
|
18
18
|
- **Per-collection config** — Override formats, quality, and dimensions per collection
|
|
19
19
|
- **Admin UI** — Status badges, file size savings, and blur previews in the sidebar
|
|
20
|
-
- **ImageBox component** — Drop-in Next.js `<Image>` wrapper with
|
|
20
|
+
- **ImageBox component** — Drop-in Next.js `<Image>` wrapper with ThumbHash blur, fade-in, responsive variant loading, and smart `sizes` defaults
|
|
21
|
+
- **Responsive variant loader** — Serves pre-generated Payload size variants directly, bypassing `/_next/image` re-optimization
|
|
22
|
+
- **Template-friendly** — `getOptimizedImageProps()` integrates with the Payload website template in 3 lines
|
|
21
23
|
- **FadeImage component** — Standalone fade-in image for custom setups using `getImageOptimizerProps()`
|
|
22
24
|
|
|
23
25
|
## Requirements
|
|
@@ -100,6 +102,7 @@ imageOptimizer({
|
|
|
100
102
|
| `maxDimensions` | `{ width: number, height: number }` | `{ width: 2560, height: 2560 }` | Maximum image dimensions. Images are resized to fit within these bounds. |
|
|
101
103
|
| `generateThumbHash` | `boolean` | `true` | Generate ThumbHash blur placeholders for instant image previews. |
|
|
102
104
|
| `stripMetadata` | `boolean` | `true` | Remove EXIF and other metadata from images. |
|
|
105
|
+
| `uniqueFileNames` | `boolean` | `false` | Replace filenames with UUIDs (e.g., `photo.jpg` → `a1b2c3d4.webp`). Prevents Vercel Blob "already exists" errors on uploads and regeneration. |
|
|
103
106
|
| `clientOptimization` | `boolean` | `true` | Pre-resize images in the browser before upload using Canvas API. Reduces upload size by up to 90% for large images. |
|
|
104
107
|
| `disabled` | `boolean` | `false` | Disable optimization while keeping schema fields intact. |
|
|
105
108
|
|
|
@@ -181,6 +184,32 @@ vercelBlobStorage({
|
|
|
181
184
|
|
|
182
185
|
With `clientUploads: true`, files upload directly from the browser to Vercel Blob (up to 5TB) and the server only handles the small JSON metadata payload. This eliminates body size limit errors regardless of file size.
|
|
183
186
|
|
|
187
|
+
#### "This blob already exists" error
|
|
188
|
+
|
|
189
|
+
When `replaceOriginal: true` (default), the plugin changes filenames during upload (e.g., `photo.jpg` → `photo.webp`). If a blob with that name already exists, Vercel Blob throws an error because `@payloadcms/storage-vercel-blob` does not pass [`allowOverwrite`](https://vercel.com/docs/vercel-blob#overwriting-blobs) to the Vercel Blob SDK.
|
|
190
|
+
|
|
191
|
+
**Fix:** Enable `uniqueFileNames` in the plugin config — replaces original filenames with UUIDs before the storage adapter sees them:
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
imageOptimizer({
|
|
195
|
+
collections: { media: true },
|
|
196
|
+
uniqueFileNames: true, // photo.jpg → a1b2c3d4-5e6f-7890-abcd-ef1234567890.webp
|
|
197
|
+
})
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
This prevents collisions on both initial uploads and bulk regeneration (the regeneration task also generates a new UUID for cloud storage re-uploads). Payload stores the full URL in the database, so UUID filenames are transparent to your application.
|
|
201
|
+
|
|
202
|
+
**Alternative:** If you prefer to keep original filenames, set `addRandomSuffix: true` on the storage adapter instead:
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
vercelBlobStorage({
|
|
206
|
+
collections: { media: true },
|
|
207
|
+
token: process.env.BLOB_READ_WRITE_TOKEN,
|
|
208
|
+
clientUploads: true,
|
|
209
|
+
addRandomSuffix: true,
|
|
210
|
+
})
|
|
211
|
+
```
|
|
212
|
+
|
|
184
213
|
## How It Differs from Payload's Default Image Handling
|
|
185
214
|
|
|
186
215
|
Payload CMS ships with [sharp](https://sharp.pixelplumbing.com/) built-in and can resize images and generate sizes on upload. This plugin **does not double-process your images** — it intercepts the raw upload in a `beforeChange` hook *before* Payload's own sharp pipeline runs, and writes the optimized buffer back to `req.file.data`. When Payload's built-in `uploadFiles` step kicks in to generate your configured sizes, it works from the already-optimized file, not the raw original.
|
|
@@ -218,32 +247,63 @@ The plugin adds an **Optimization Status** panel to the document sidebar showing
|
|
|
218
247
|
|
|
219
248
|
A **Regenerate Images** button appears in collection list views, allowing you to bulk re-process existing images with a real-time progress bar.
|
|
220
249
|
|
|
221
|
-
##
|
|
250
|
+
## Displaying Images
|
|
222
251
|
|
|
223
|
-
|
|
252
|
+
### Option 1: `ImageBox` (New Projects)
|
|
253
|
+
|
|
254
|
+
Drop-in Next.js `<Image>` wrapper — the easiest way to display images with best practices:
|
|
224
255
|
|
|
225
256
|
```tsx
|
|
226
257
|
import { ImageBox } from '@inoo-ch/payload-image-optimizer/client'
|
|
227
258
|
|
|
228
|
-
//
|
|
229
|
-
<ImageBox media={doc.heroImage} alt="Hero" />
|
|
259
|
+
// Hero image — fill mode with priority
|
|
260
|
+
<ImageBox media={doc.heroImage} alt="Hero" fill priority />
|
|
261
|
+
|
|
262
|
+
// Card grid — explicit sizes hint
|
|
263
|
+
<ImageBox media={doc.image} alt="Card" fill sizes="(max-width: 768px) 100vw, 33vw" />
|
|
264
|
+
|
|
265
|
+
// Fixed dimensions
|
|
266
|
+
<ImageBox media={doc.avatar} alt="Avatar" width={64} height={64} fade={false} />
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
**What it does automatically:**
|
|
270
|
+
- Per-image ThumbHash blur placeholder
|
|
271
|
+
- Smooth blur-to-sharp fade transition
|
|
272
|
+
- Focal point positioning from `focalX`/`focalY`
|
|
273
|
+
- Responsive variant loader — serves pre-generated Payload size variants directly instead of `/_next/image` re-optimization (when `imageSizes` is configured on the collection)
|
|
274
|
+
- Smart `sizes` default for fill mode — `(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw` instead of the browser's `100vw` assumption
|
|
275
|
+
- Cache busting via `updatedAt`
|
|
230
276
|
|
|
231
|
-
|
|
232
|
-
<ImageBox media="/images/photo.jpg" alt="Photo" width={800} height={600} />
|
|
277
|
+
### Option 2: `getOptimizedImageProps()` (Existing Projects / Payload Website Template)
|
|
233
278
|
|
|
234
|
-
|
|
235
|
-
<ImageBox media={doc.image} alt="Photo" fade={false} />
|
|
279
|
+
If you're using the [Payload website template](https://github.com/payloadcms/payload/tree/main/templates/website) or have an existing `<NextImage>` component, add 3 lines:
|
|
236
280
|
|
|
237
|
-
|
|
238
|
-
|
|
281
|
+
```tsx
|
|
282
|
+
import { getOptimizedImageProps } from '@inoo-ch/payload-image-optimizer/client'
|
|
283
|
+
|
|
284
|
+
const optimizedProps = getOptimizedImageProps(resource)
|
|
285
|
+
|
|
286
|
+
<NextImage
|
|
287
|
+
{...optimizedProps} // ThumbHash blur, focal point, variant loader
|
|
288
|
+
src={src}
|
|
289
|
+
alt={alt}
|
|
290
|
+
fill={fill}
|
|
291
|
+
sizes={sizes}
|
|
292
|
+
quality={80}
|
|
293
|
+
/>
|
|
239
294
|
```
|
|
240
295
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
296
|
+
This replaces the template's hardcoded blur placeholder with per-image ThumbHash, adds focal point support, and enables responsive variant loading.
|
|
297
|
+
|
|
298
|
+
### Responsive Variant Loading
|
|
299
|
+
|
|
300
|
+
When your collection has `imageSizes` configured (e.g., `thumbnail: 300`, `medium: 900`, `large: 1400`), both `ImageBox` and `getOptimizedImageProps()` automatically create a hybrid `next/image` loader that:
|
|
301
|
+
|
|
302
|
+
1. Picks the smallest pre-generated variant >= the requested width
|
|
303
|
+
2. Serves it directly from your storage (bypasses `/_next/image` — no double optimization)
|
|
304
|
+
3. Falls back to `/_next/image` when no close variant match exists
|
|
305
|
+
|
|
306
|
+
This means images uploaded to collections with `imageSizes` get responsive loading for free — no extra config needed.
|
|
247
307
|
|
|
248
308
|
## Document Schema
|
|
249
309
|
|
|
@@ -310,8 +370,9 @@ Copy-paste this instruction to your AI coding agent to have it autonomously inte
|
|
|
310
370
|
>
|
|
311
371
|
> 1. Which upload collections should be optimized and with what settings
|
|
312
372
|
> 2. Whether to use `replaceOriginal` or keep originals alongside variants
|
|
313
|
-
> 3.
|
|
314
|
-
> 4.
|
|
373
|
+
> 3. For **new components**: use `<ImageBox>` — it handles ThumbHash blur, fade-in, focal point, responsive variant loading, and smart `sizes` defaults automatically
|
|
374
|
+
> 4. For **existing components** (especially the Payload website template's `ImageMedia`): use `getOptimizedImageProps(resource)` — a single spread that adds ThumbHash, focal point, and variant loader to any `<NextImage>`
|
|
375
|
+
> 5. If collections have `imageSizes` configured, the variant loader will automatically serve pre-generated size variants directly instead of going through `/_next/image` re-optimization
|
|
315
376
|
>
|
|
316
377
|
> Use the zero-config default (`collections: { <slug>: true }`) unless the project has specific requirements that call for custom settings.
|
|
317
378
|
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
-
import React, { useState } from 'react';
|
|
3
|
+
import React, { useMemo, useState } from 'react';
|
|
4
4
|
import NextImage from 'next/image';
|
|
5
5
|
import { getImageOptimizerProps } from '../utilities/getImageOptimizerProps.js';
|
|
6
|
+
import { createVariantLoader, getDefaultSizes } from '../utilities/responsiveImage.js';
|
|
6
7
|
export const ImageBox = ({ media, alt: altFromProps, fill, sizes, priority, loading: loadingFromProps, style: styleFromProps, fade = true, fadeDuration = 500, ...props })=>{
|
|
7
8
|
const [loaded, setLoaded] = useState(false);
|
|
8
9
|
const loading = priority ? undefined : loadingFromProps ?? 'lazy';
|
|
@@ -17,7 +18,7 @@ export const ImageBox = ({ media, alt: altFromProps, fill, sizes, priority, load
|
|
|
17
18
|
alt: altFromProps || '',
|
|
18
19
|
quality: 80,
|
|
19
20
|
fill: fill,
|
|
20
|
-
sizes: sizes,
|
|
21
|
+
sizes: sizes ?? getDefaultSizes(fill),
|
|
21
22
|
style: {
|
|
22
23
|
objectFit: 'cover',
|
|
23
24
|
objectPosition: 'center',
|
|
@@ -34,6 +35,9 @@ export const ImageBox = ({ media, alt: altFromProps, fill, sizes, priority, load
|
|
|
34
35
|
const alt = altFromProps || media.alt || media.filename || '';
|
|
35
36
|
const src = media.url ? `${media.url}${media.updatedAt ? `?${media.updatedAt}` : ''}` : '';
|
|
36
37
|
const optimizerProps = getImageOptimizerProps(media);
|
|
38
|
+
const variantLoader = useMemo(()=>createVariantLoader(media), [
|
|
39
|
+
media
|
|
40
|
+
]);
|
|
37
41
|
return /*#__PURE__*/ _jsx(NextImage, {
|
|
38
42
|
...props,
|
|
39
43
|
src: src,
|
|
@@ -42,7 +46,8 @@ export const ImageBox = ({ media, alt: altFromProps, fill, sizes, priority, load
|
|
|
42
46
|
fill: fill,
|
|
43
47
|
width: !fill ? width : undefined,
|
|
44
48
|
height: !fill ? height : undefined,
|
|
45
|
-
sizes: sizes,
|
|
49
|
+
sizes: sizes ?? getDefaultSizes(fill),
|
|
50
|
+
loader: variantLoader,
|
|
46
51
|
style: {
|
|
47
52
|
objectFit: 'cover',
|
|
48
53
|
...optimizerProps.style,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/ImageBox.tsx"],"sourcesContent":["'use client'\n\nimport React, { useState } from 'react'\nimport NextImage, { type ImageProps } from 'next/image'\nimport type { MediaResource } from '../types.js'\nimport { getImageOptimizerProps } from '../utilities/getImageOptimizerProps.js'\n\nexport interface ImageBoxProps extends Omit<ImageProps, 'src' | 'alt'> {\n media: MediaResource | string\n alt?: string\n /** Enable smooth blur-to-sharp fade transition on load. Defaults to `true`. */\n fade?: boolean\n /** Duration of the fade animation in milliseconds. Defaults to `500`. */\n fadeDuration?: number\n}\n\nexport const ImageBox: React.FC<ImageBoxProps> = ({\n media,\n alt: altFromProps,\n fill,\n sizes,\n priority,\n loading: loadingFromProps,\n style: styleFromProps,\n fade = true,\n fadeDuration = 500,\n ...props\n}) => {\n const [loaded, setLoaded] = useState(false)\n const loading = priority ? undefined : (loadingFromProps ?? 'lazy')\n\n const fadeStyle = fade\n ? {\n filter: loaded ? 'blur(0px)' : 'blur(20px)',\n transition: loaded ? `filter ${fadeDuration}ms ease-in-out` : undefined,\n }\n : undefined\n\n if (typeof media === 'string') {\n return (\n <NextImage\n {...props}\n src={media}\n alt={altFromProps || ''}\n quality={80}\n fill={fill}\n sizes={sizes}\n style={{ objectFit: 'cover', objectPosition: 'center', ...fadeStyle, ...styleFromProps }}\n priority={priority}\n loading={loading}\n onLoad={fade ? () => setLoaded(true) : undefined}\n />\n )\n }\n\n const width = media.width ?? undefined\n const height = media.height ?? undefined\n const alt = altFromProps || (media as any).alt || media.filename || ''\n const src = media.url ? `${media.url}${media.updatedAt ? `?${media.updatedAt}` : ''}` : ''\n\n const optimizerProps = getImageOptimizerProps(media)\n\n return (\n <NextImage\n {...props}\n src={src}\n alt={alt}\n quality={80}\n fill={fill}\n width={!fill ? width : undefined}\n height={!fill ? height : undefined}\n sizes={sizes}\n style={{ objectFit: 'cover', ...optimizerProps.style, ...fadeStyle, ...styleFromProps }}\n placeholder={optimizerProps.placeholder}\n blurDataURL={optimizerProps.blurDataURL}\n priority={priority}\n loading={loading}\n onLoad={fade ? () => setLoaded(true) : undefined}\n />\n )\n}\n"],"names":["React","useState","NextImage","getImageOptimizerProps","ImageBox","media","alt","altFromProps","fill","sizes","priority","loading","loadingFromProps","style","styleFromProps","fade","fadeDuration","props","loaded","setLoaded","undefined","fadeStyle","filter","transition","src","quality","objectFit","objectPosition","onLoad","width","height","filename","url","updatedAt","optimizerProps","placeholder","blurDataURL"],"mappings":"AAAA;;AAEA,OAAOA,SAASC,QAAQ,QAAQ,QAAO;
|
|
1
|
+
{"version":3,"sources":["../../src/components/ImageBox.tsx"],"sourcesContent":["'use client'\n\nimport React, { useMemo, useState } from 'react'\nimport NextImage, { type ImageProps } from 'next/image'\nimport type { MediaResource } from '../types.js'\nimport { getImageOptimizerProps } from '../utilities/getImageOptimizerProps.js'\nimport { createVariantLoader, getDefaultSizes } from '../utilities/responsiveImage.js'\n\nexport interface ImageBoxProps extends Omit<ImageProps, 'src' | 'alt'> {\n media: MediaResource | string\n alt?: string\n /** Enable smooth blur-to-sharp fade transition on load. Defaults to `true`. */\n fade?: boolean\n /** Duration of the fade animation in milliseconds. Defaults to `500`. */\n fadeDuration?: number\n}\n\nexport const ImageBox: React.FC<ImageBoxProps> = ({\n media,\n alt: altFromProps,\n fill,\n sizes,\n priority,\n loading: loadingFromProps,\n style: styleFromProps,\n fade = true,\n fadeDuration = 500,\n ...props\n}) => {\n const [loaded, setLoaded] = useState(false)\n const loading = priority ? undefined : (loadingFromProps ?? 'lazy')\n\n const fadeStyle = fade\n ? {\n filter: loaded ? 'blur(0px)' : 'blur(20px)',\n transition: loaded ? `filter ${fadeDuration}ms ease-in-out` : undefined,\n }\n : undefined\n\n if (typeof media === 'string') {\n return (\n <NextImage\n {...props}\n src={media}\n alt={altFromProps || ''}\n quality={80}\n fill={fill}\n sizes={sizes ?? getDefaultSizes(fill)}\n style={{ objectFit: 'cover', objectPosition: 'center', ...fadeStyle, ...styleFromProps }}\n priority={priority}\n loading={loading}\n onLoad={fade ? () => setLoaded(true) : undefined}\n />\n )\n }\n\n const width = media.width ?? undefined\n const height = media.height ?? undefined\n const alt = altFromProps || (media as any).alt || media.filename || ''\n const src = media.url ? `${media.url}${media.updatedAt ? `?${media.updatedAt}` : ''}` : ''\n\n const optimizerProps = getImageOptimizerProps(media)\n const variantLoader = useMemo(() => createVariantLoader(media), [media])\n\n return (\n <NextImage\n {...props}\n src={src}\n alt={alt}\n quality={80}\n fill={fill}\n width={!fill ? width : undefined}\n height={!fill ? height : undefined}\n sizes={sizes ?? getDefaultSizes(fill)}\n loader={variantLoader}\n style={{ objectFit: 'cover', ...optimizerProps.style, ...fadeStyle, ...styleFromProps }}\n placeholder={optimizerProps.placeholder}\n blurDataURL={optimizerProps.blurDataURL}\n priority={priority}\n loading={loading}\n onLoad={fade ? () => setLoaded(true) : undefined}\n />\n )\n}\n"],"names":["React","useMemo","useState","NextImage","getImageOptimizerProps","createVariantLoader","getDefaultSizes","ImageBox","media","alt","altFromProps","fill","sizes","priority","loading","loadingFromProps","style","styleFromProps","fade","fadeDuration","props","loaded","setLoaded","undefined","fadeStyle","filter","transition","src","quality","objectFit","objectPosition","onLoad","width","height","filename","url","updatedAt","optimizerProps","variantLoader","loader","placeholder","blurDataURL"],"mappings":"AAAA;;AAEA,OAAOA,SAASC,OAAO,EAAEC,QAAQ,QAAQ,QAAO;AAChD,OAAOC,eAAoC,aAAY;AAEvD,SAASC,sBAAsB,QAAQ,yCAAwC;AAC/E,SAASC,mBAAmB,EAAEC,eAAe,QAAQ,kCAAiC;AAWtF,OAAO,MAAMC,WAAoC,CAAC,EAChDC,KAAK,EACLC,KAAKC,YAAY,EACjBC,IAAI,EACJC,KAAK,EACLC,QAAQ,EACRC,SAASC,gBAAgB,EACzBC,OAAOC,cAAc,EACrBC,OAAO,IAAI,EACXC,eAAe,GAAG,EAClB,GAAGC,OACJ;IACC,MAAM,CAACC,QAAQC,UAAU,GAAGpB,SAAS;IACrC,MAAMY,UAAUD,WAAWU,YAAaR,oBAAoB;IAE5D,MAAMS,YAAYN,OACd;QACEO,QAAQJ,SAAS,cAAc;QAC/BK,YAAYL,SAAS,CAAC,OAAO,EAAEF,aAAa,cAAc,CAAC,GAAGI;IAChE,IACAA;IAEJ,IAAI,OAAOf,UAAU,UAAU;QAC7B,qBACE,KAACL;YACE,GAAGiB,KAAK;YACTO,KAAKnB;YACLC,KAAKC,gBAAgB;YACrBkB,SAAS;YACTjB,MAAMA;YACNC,OAAOA,SAASN,gBAAgBK;YAChCK,OAAO;gBAAEa,WAAW;gBAASC,gBAAgB;gBAAU,GAAGN,SAAS;gBAAE,GAAGP,cAAc;YAAC;YACvFJ,UAAUA;YACVC,SAASA;YACTiB,QAAQb,OAAO,IAAMI,UAAU,QAAQC;;IAG7C;IAEA,MAAMS,QAAQxB,MAAMwB,KAAK,IAAIT;IAC7B,MAAMU,SAASzB,MAAMyB,MAAM,IAAIV;IAC/B,MAAMd,MAAMC,gBAAgB,AAACF,MAAcC,GAAG,IAAID,MAAM0B,QAAQ,IAAI;IACpE,MAAMP,MAAMnB,MAAM2B,GAAG,GAAG,GAAG3B,MAAM2B,GAAG,GAAG3B,MAAM4B,SAAS,GAAG,CAAC,CAAC,EAAE5B,MAAM4B,SAAS,EAAE,GAAG,IAAI,GAAG;IAExF,MAAMC,iBAAiBjC,uBAAuBI;IAC9C,MAAM8B,gBAAgBrC,QAAQ,IAAMI,oBAAoBG,QAAQ;QAACA;KAAM;IAEvE,qBACE,KAACL;QACE,GAAGiB,KAAK;QACTO,KAAKA;QACLlB,KAAKA;QACLmB,SAAS;QACTjB,MAAMA;QACNqB,OAAO,CAACrB,OAAOqB,QAAQT;QACvBU,QAAQ,CAACtB,OAAOsB,SAASV;QACzBX,OAAOA,SAASN,gBAAgBK;QAChC4B,QAAQD;QACRtB,OAAO;YAAEa,WAAW;YAAS,GAAGQ,eAAerB,KAAK;YAAE,GAAGQ,SAAS;YAAE,GAAGP,cAAc;QAAC;QACtFuB,aAAaH,eAAeG,WAAW;QACvCC,aAAaJ,eAAeI,WAAW;QACvC5B,UAAUA;QACVC,SAASA;QACTiB,QAAQb,OAAO,IAAMI,UAAU,QAAQC;;AAG7C,EAAC"}
|
package/dist/defaults.js
CHANGED
|
@@ -14,7 +14,8 @@ export const resolveConfig = (config)=>({
|
|
|
14
14
|
height: 2560
|
|
15
15
|
},
|
|
16
16
|
replaceOriginal: config.replaceOriginal ?? true,
|
|
17
|
-
stripMetadata: config.stripMetadata ?? true
|
|
17
|
+
stripMetadata: config.stripMetadata ?? true,
|
|
18
|
+
uniqueFileNames: config.uniqueFileNames ?? false
|
|
18
19
|
});
|
|
19
20
|
export const resolveCollectionConfig = (resolvedConfig, collectionSlug)=>{
|
|
20
21
|
const collectionValue = resolvedConfig.collections[collectionSlug];
|
package/dist/defaults.js.map
CHANGED
|
@@ -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 clientOptimization: config.clientOptimization ?? true,\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","clientOptimization","collections","disabled","formats","format","quality","generateThumbHash","maxDimensions","width","height","replaceOriginal","stripMetadata","resolveCollectionConfig","resolvedConfig","collectionSlug","collectionValue"],"mappings":"AAIA,OAAO,MAAMA,gBAAgB,CAACC,SAAgE,CAAA;QAC5FC,oBAAoBD,OAAOC,kBAAkB,IAAI;QACjDC,aAAaF,OAAOE,WAAW;QAC/BC,UAAUH,OAAOG,QAAQ,IAAI;QAC7BC,SAASJ,OAAOI,OAAO,IAAI;YACzB;gBAAEC,QAAQ;gBAAQC,SAAS;YAAG;SAC/B;QACDC,mBAAmBP,OAAOO,iBAAiB,IAAI;QAC/CC,eAAeR,OAAOQ,aAAa,IAAI;YAAEC,OAAO;YAAMC,QAAQ;QAAK;QACnEC,iBAAiBX,OAAOW,eAAe,IAAI;QAC3CC,eAAeZ,OAAOY,aAAa,IAAI;
|
|
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 clientOptimization: config.clientOptimization ?? true,\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 uniqueFileNames: config.uniqueFileNames ?? false,\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","clientOptimization","collections","disabled","formats","format","quality","generateThumbHash","maxDimensions","width","height","replaceOriginal","stripMetadata","uniqueFileNames","resolveCollectionConfig","resolvedConfig","collectionSlug","collectionValue"],"mappings":"AAIA,OAAO,MAAMA,gBAAgB,CAACC,SAAgE,CAAA;QAC5FC,oBAAoBD,OAAOC,kBAAkB,IAAI;QACjDC,aAAaF,OAAOE,WAAW;QAC/BC,UAAUH,OAAOG,QAAQ,IAAI;QAC7BC,SAASJ,OAAOI,OAAO,IAAI;YACzB;gBAAEC,QAAQ;gBAAQC,SAAS;YAAG;SAC/B;QACDC,mBAAmBP,OAAOO,iBAAiB,IAAI;QAC/CC,eAAeR,OAAOQ,aAAa,IAAI;YAAEC,OAAO;YAAMC,QAAQ;QAAK;QACnEC,iBAAiBX,OAAOW,eAAe,IAAI;QAC3CC,eAAeZ,OAAOY,aAAa,IAAI;QACvCC,iBAAiBb,OAAOa,eAAe,IAAI;IAC7C,CAAA,EAAE;AAEF,OAAO,MAAMC,0BAA0B,CACrCC,gBACAC;IAEA,MAAMC,kBAAkBF,eAAeb,WAAW,CAACc,eAAiC;IAEpF,IAAI,CAACC,mBAAmBA,oBAAoB,MAAM;QAChD,OAAO;YACLb,SAASW,eAAeX,OAAO;YAC/BI,eAAeO,eAAeP,aAAa;YAC3CG,iBAAiBI,eAAeJ,eAAe;QACjD;IACF;IAEA,OAAO;QACLP,SAASa,gBAAgBb,OAAO,IAAIW,eAAeX,OAAO;QAC1DI,eAAeS,gBAAgBT,aAAa,IAAIO,eAAeP,aAAa;QAC5EG,iBAAiBM,gBAAgBN,eAAe,IAAII,eAAeJ,eAAe;IACpF;AACF,EAAC"}
|
package/dist/exports/client.d.ts
CHANGED
|
@@ -5,5 +5,8 @@ export { FadeImage } from '../components/FadeImage.js';
|
|
|
5
5
|
export type { FadeImageProps } from '../components/FadeImage.js';
|
|
6
6
|
export { getImageOptimizerProps } from '../utilities/getImageOptimizerProps.js';
|
|
7
7
|
export type { ImageOptimizerProps } from '../utilities/getImageOptimizerProps.js';
|
|
8
|
+
export { getOptimizedImageProps } from '../utilities/getOptimizedImageProps.js';
|
|
9
|
+
export type { OptimizedImageProps } from '../utilities/getOptimizedImageProps.js';
|
|
10
|
+
export { createVariantLoader, getDefaultSizes } from '../utilities/responsiveImage.js';
|
|
8
11
|
export { RegenerationButton } from '../components/RegenerationButton.js';
|
|
9
12
|
export { UploadOptimizer } from '../components/UploadOptimizer.js';
|
package/dist/exports/client.js
CHANGED
|
@@ -2,6 +2,8 @@ export { OptimizationStatus } from '../components/OptimizationStatus.js';
|
|
|
2
2
|
export { ImageBox } from '../components/ImageBox.js';
|
|
3
3
|
export { FadeImage } from '../components/FadeImage.js';
|
|
4
4
|
export { getImageOptimizerProps } from '../utilities/getImageOptimizerProps.js';
|
|
5
|
+
export { getOptimizedImageProps } from '../utilities/getOptimizedImageProps.js';
|
|
6
|
+
export { createVariantLoader, getDefaultSizes } from '../utilities/responsiveImage.js';
|
|
5
7
|
export { RegenerationButton } from '../components/RegenerationButton.js';
|
|
6
8
|
export { UploadOptimizer } from '../components/UploadOptimizer.js';
|
|
7
9
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/exports/client.ts"],"sourcesContent":["export { OptimizationStatus } from '../components/OptimizationStatus.js'\nexport { ImageBox } from '../components/ImageBox.js'\nexport type { ImageBoxProps } from '../components/ImageBox.js'\nexport { FadeImage } from '../components/FadeImage.js'\nexport type { FadeImageProps } from '../components/FadeImage.js'\nexport { getImageOptimizerProps } from '../utilities/getImageOptimizerProps.js'\nexport type { ImageOptimizerProps } from '../utilities/getImageOptimizerProps.js'\nexport { RegenerationButton } from '../components/RegenerationButton.js'\nexport { UploadOptimizer } from '../components/UploadOptimizer.js'\n"],"names":["OptimizationStatus","ImageBox","FadeImage","getImageOptimizerProps","RegenerationButton","UploadOptimizer"],"mappings":"AAAA,SAASA,kBAAkB,QAAQ,sCAAqC;AACxE,SAASC,QAAQ,QAAQ,4BAA2B;AAEpD,SAASC,SAAS,QAAQ,6BAA4B;AAEtD,SAASC,sBAAsB,QAAQ,yCAAwC;AAE/E,SAASC,kBAAkB,QAAQ,sCAAqC;AACxE,SAASC,eAAe,QAAQ,mCAAkC"}
|
|
1
|
+
{"version":3,"sources":["../../src/exports/client.ts"],"sourcesContent":["export { OptimizationStatus } from '../components/OptimizationStatus.js'\nexport { ImageBox } from '../components/ImageBox.js'\nexport type { ImageBoxProps } from '../components/ImageBox.js'\nexport { FadeImage } from '../components/FadeImage.js'\nexport type { FadeImageProps } from '../components/FadeImage.js'\nexport { getImageOptimizerProps } from '../utilities/getImageOptimizerProps.js'\nexport type { ImageOptimizerProps } from '../utilities/getImageOptimizerProps.js'\nexport { getOptimizedImageProps } from '../utilities/getOptimizedImageProps.js'\nexport type { OptimizedImageProps } from '../utilities/getOptimizedImageProps.js'\nexport { createVariantLoader, getDefaultSizes } from '../utilities/responsiveImage.js'\nexport { RegenerationButton } from '../components/RegenerationButton.js'\nexport { UploadOptimizer } from '../components/UploadOptimizer.js'\n"],"names":["OptimizationStatus","ImageBox","FadeImage","getImageOptimizerProps","getOptimizedImageProps","createVariantLoader","getDefaultSizes","RegenerationButton","UploadOptimizer"],"mappings":"AAAA,SAASA,kBAAkB,QAAQ,sCAAqC;AACxE,SAASC,QAAQ,QAAQ,4BAA2B;AAEpD,SAASC,SAAS,QAAQ,6BAA4B;AAEtD,SAASC,sBAAsB,QAAQ,yCAAwC;AAE/E,SAASC,sBAAsB,QAAQ,yCAAwC;AAE/E,SAASC,mBAAmB,EAAEC,eAAe,QAAQ,kCAAiC;AACtF,SAASC,kBAAkB,QAAQ,sCAAqC;AACxE,SAASC,eAAe,QAAQ,mCAAkC"}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
1
2
|
import path from 'path';
|
|
2
3
|
import { resolveCollectionConfig } from '../defaults.js';
|
|
3
4
|
import { convertFormat, generateThumbHash, stripAndResize } from '../processing/index.js';
|
|
@@ -6,6 +7,15 @@ export const createBeforeChangeHook = (resolvedConfig, collectionSlug)=>{
|
|
|
6
7
|
return async ({ context, data, req })=>{
|
|
7
8
|
if (context?.imageOptimizer_skip) return data;
|
|
8
9
|
if (!req.file || !req.file.data || !req.file.mimetype?.startsWith('image/')) return data;
|
|
10
|
+
// Rename file to UUID before any processing, so the storage adapter
|
|
11
|
+
// never sees the original filename. Prevents Vercel Blob "already exists"
|
|
12
|
+
// errors and avoids leaking original filenames to storage.
|
|
13
|
+
if (resolvedConfig.uniqueFileNames) {
|
|
14
|
+
const ext = path.extname(req.file.name);
|
|
15
|
+
const uuid = crypto.randomUUID();
|
|
16
|
+
req.file.name = `${uuid}${ext}`;
|
|
17
|
+
data.filename = req.file.name;
|
|
18
|
+
}
|
|
9
19
|
const originalSize = req.file.data.length;
|
|
10
20
|
const perCollectionConfig = resolveCollectionConfig(resolvedConfig, collectionSlug);
|
|
11
21
|
// Process in memory: strip EXIF, resize, generate blur
|