@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 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 automatic ThumbHash blur placeholders, focal point support, and smooth fade-in transition.
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
- // With a Payload media resource object
230
- <ImageBox media={doc.image} alt="Hero" fill sizes="100vw" />
233
+ // Pass the full Payload media document — ImageBox handles everything
234
+ <ImageBox media={doc.heroImage} alt="Hero" fill priority />
231
235
 
232
- // With a plain URL string
233
- <ImageBox media="/images/fallback.jpg" alt="Fallback" width={800} height={600} />
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
- // Disable fade animation
236
- <ImageBox media={doc.image} alt="Photo" fade={false} />
239
+ // Fixed dimensions (non-fill)
240
+ <ImageBox media={doc.avatar} alt="Avatar" width={64} height={64} fade={false} />
237
241
 
238
- // Custom fade duration
239
- <ImageBox media={doc.image} alt="Photo" fadeDuration={300} />
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
- Automatically applies:
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
- ### `FadeImage` Component
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
- 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`.
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 { FadeImage, getImageOptimizerProps } from '@inoo-ch/payload-image-optimizer/client'
271
+ import { getOptimizedImageProps } from '@inoo-ch/payload-image-optimizer/client'
264
272
 
265
- const optimizerProps = getImageOptimizerProps(resource)
273
+ // In your existing ImageMedia component — 3 lines to add:
274
+ const optimizedProps = getOptimizedImageProps(resource)
266
275
 
267
- <FadeImage
268
- src={resource.url}
269
- alt=""
270
- width={800}
271
- height={600}
272
- optimizerProps={optimizerProps}
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
- **Props:** Extends all Next.js `ImageProps` (except `placeholder`, `blurDataURL`, `onLoad`), plus:
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
- | Prop | Type | Default | Description |
279
- |------|------|---------|-------------|
280
- | `optimizerProps` | `ImageOptimizerProps` | | Props returned by `getImageOptimizerProps()` |
281
- | `fadeDuration` | `number` | `500` | Duration of the fade animation in milliseconds |
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
- For integrating with existing image components (e.g., the Payload website template's `ImageMedia`):
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, // data URL from ThumbHash (only when placeholder is 'blur')
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 automatic ThumbHash blur and smooth fade-in
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
- ## ImageBox Component
250
+ ## Displaying Images
222
251
 
223
- The plugin exports an `ImageBox` component — a Next.js `<Image>` wrapper that automatically applies ThumbHash blur placeholders with a smooth blur-to-sharp fade transition:
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
- // Pass a Payload media document directly
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
- // Or use a plain URL string
232
- <ImageBox media="/images/photo.jpg" alt="Photo" width={800} height={600} />
277
+ ### Option 2: `getOptimizedImageProps()` (Existing Projects / Payload Website Template)
233
278
 
234
- // Disable fade animation
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
- // Custom fade duration (default: 500ms)
238
- <ImageBox media={doc.image} alt="Photo" fadeDuration={300} />
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
- **Features:**
242
- - Automatic ThumbHash `blurDataURL` from the media document
243
- - Smooth blur-to-sharp fade transition on load (enabled by default)
244
- - Respects Payload focal point (`focalX` / `focalY`) for `objectPosition`
245
- - Lazy loading by default, with `priority` prop for above-the-fold images
246
- - Cache busting via `updatedAt` timestamp
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. Where to add `<ImageBox>`, `<FadeImage>`, or `getImageOptimizerProps()` in the frontend for ThumbHash blur placeholders with smooth fade-in and focal point support
314
- > 4. Whether any existing image rendering code should use the optimized variants
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;AACvC,OAAOC,eAAoC,aAAY;AAEvD,SAASC,sBAAsB,QAAQ,yCAAwC;AAW/E,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,GAAGlB,SAAS;IACrC,MAAMU,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,KAACH;YACE,GAAGe,KAAK;YACTO,KAAKnB;YACLC,KAAKC,gBAAgB;YACrBkB,SAAS;YACTjB,MAAMA;YACNC,OAAOA;YACPI,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,iBAAiB/B,uBAAuBE;IAE9C,qBACE,KAACH;QACE,GAAGe,KAAK;QACTO,KAAKA;QACLlB,KAAKA;QACLmB,SAAS;QACTjB,MAAMA;QACNqB,OAAO,CAACrB,OAAOqB,QAAQT;QACvBU,QAAQ,CAACtB,OAAOsB,SAASV;QACzBX,OAAOA;QACPI,OAAO;YAAEa,WAAW;YAAS,GAAGQ,eAAerB,KAAK;YAAE,GAAGQ,SAAS;YAAE,GAAGP,cAAc;QAAC;QACtFqB,aAAaD,eAAeC,WAAW;QACvCC,aAAaF,eAAeE,WAAW;QACvC1B,UAAUA;QACVC,SAASA;QACTiB,QAAQb,OAAO,IAAMI,UAAU,QAAQC;;AAG7C,EAAC"}
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];
@@ -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;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
+ {"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"}
@@ -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';
@@ -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