@inoo-ch/payload-image-optimizer 1.1.0 → 1.1.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 ADDED
@@ -0,0 +1,383 @@
1
+ # @inoo-ch/payload-image-optimizer
2
+
3
+ Payload CMS plugin for automatic image optimization — WebP/AVIF conversion, resize, EXIF strip, ThumbHash blur placeholders, and bulk regeneration.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pnpm add @inoo-ch/payload-image-optimizer
9
+ ```
10
+
11
+ **Peer dependency:** Payload 3.x must provide `sharp` in its config.
12
+
13
+ ## Quick Start (Zero-Config)
14
+
15
+ ```ts
16
+ // payload.config.ts
17
+ import { imageOptimizer } from '@inoo-ch/payload-image-optimizer'
18
+
19
+ export default buildConfig({
20
+ // ...
21
+ plugins: [
22
+ imageOptimizer({
23
+ collections: {
24
+ media: true, // enable with all defaults
25
+ },
26
+ }),
27
+ ],
28
+ sharp, // required — Payload must be configured with sharp
29
+ })
30
+ ```
31
+
32
+ With this minimal config every uploaded image in the `media` collection will automatically:
33
+
34
+ 1. Be resized to fit within 2560x2560 (no upscaling)
35
+ 2. Have EXIF/metadata stripped
36
+ 3. Be converted to WebP (quality 80) — replacing the original file on disk
37
+ 4. Get a ThumbHash blur placeholder generated
38
+
39
+ ## Configuration Reference
40
+
41
+ ### Plugin Options (`ImageOptimizerConfig`)
42
+
43
+ ```ts
44
+ imageOptimizer({
45
+ // Required — map of collection slugs to optimize.
46
+ // Use `true` for defaults, or an object for per-collection overrides.
47
+ collections: {
48
+ media: true,
49
+ avatars: {
50
+ maxDimensions: { width: 256, height: 256 },
51
+ formats: [{ format: 'webp', quality: 90 }],
52
+ replaceOriginal: false,
53
+ },
54
+ },
55
+
56
+ // Global defaults (all optional — values shown are the defaults):
57
+ formats: [{ format: 'webp', quality: 80 }], // output formats
58
+ maxDimensions: { width: 2560, height: 2560 }, // max resize dimensions
59
+ stripMetadata: true, // strip EXIF data
60
+ generateThumbHash: true, // generate blur placeholders
61
+ replaceOriginal: true, // convert main file to primary format
62
+ disabled: false, // keep fields but skip all processing
63
+ })
64
+ ```
65
+
66
+ | Option | Type | Default | Description |
67
+ |--------|------|---------|-------------|
68
+ | `collections` | `Record<slug, true \| CollectionConfig>` | — | **Required.** Collections to optimize. `true` = use global defaults. |
69
+ | `formats` | `{ format: 'webp' \| 'avif', quality: number }[]` | `[{ format: 'webp', quality: 80 }]` | Output formats to generate. |
70
+ | `maxDimensions` | `{ width: number, height: number }` | `{ width: 2560, height: 2560 }` | Maximum image dimensions (fit inside, no upscaling). |
71
+ | `stripMetadata` | `boolean` | `true` | Strip EXIF, ICC, XMP metadata. |
72
+ | `generateThumbHash` | `boolean` | `true` | Generate ThumbHash blur placeholder. |
73
+ | `replaceOriginal` | `boolean` | `true` | Replace the original file with the primary format. |
74
+ | `disabled` | `boolean` | `false` | Keep schema fields but disable all processing. |
75
+
76
+ ### Per-Collection Overrides (`CollectionOptimizerConfig`)
77
+
78
+ Each collection can override `formats`, `maxDimensions`, and `replaceOriginal`. All other settings are global-only.
79
+
80
+ ```ts
81
+ collections: {
82
+ media: true, // uses global defaults
83
+ avatars: { // overrides specific settings
84
+ formats: [{ format: 'webp', quality: 90 }],
85
+ maxDimensions: { width: 256, height: 256 },
86
+ replaceOriginal: false,
87
+ },
88
+ }
89
+ ```
90
+
91
+ ## How It Works
92
+
93
+ ### Upload Pipeline
94
+
95
+ When an image is uploaded to an optimized collection:
96
+
97
+ 1. **`beforeChange` hook** (in-memory processing):
98
+ - Auto-rotates based on EXIF orientation
99
+ - Resizes to fit within `maxDimensions`
100
+ - Strips metadata (if enabled)
101
+ - If `replaceOriginal: true`: converts to primary format (first in `formats` array), updates filename/mimeType
102
+ - Generates ThumbHash (if enabled)
103
+ - Sets `imageOptimizer.status = 'pending'`
104
+
105
+ 2. **`afterChange` hook** (disk + async):
106
+ - Writes processed buffer to disk (overwriting Payload's original)
107
+ - Cleans up old file if filename changed
108
+ - Queues `imageOptimizer_convertFormats` background job for remaining formats
109
+
110
+ 3. **Background job** (`imageOptimizer_convertFormats`):
111
+ - Generates variant files for any additional formats (e.g., AVIF)
112
+ - Writes variants to disk with `-optimized` suffix
113
+ - Updates document: `imageOptimizer.status = 'complete'`, populates `variants` array
114
+
115
+ ### File Naming
116
+
117
+ | File | Naming Pattern | Example |
118
+ |------|---------------|---------|
119
+ | Main file (replaceOriginal) | `{name}.{primaryFormat}` | `photo.webp` |
120
+ | Variant files | `{name}-optimized.{format}` | `photo-optimized.avif` |
121
+
122
+ ### Format Behavior
123
+
124
+ **When `replaceOriginal: true`** (default):
125
+ - The uploaded file is converted to the first format in the `formats` array and replaces the original on disk.
126
+ - Additional formats are generated as variant files.
127
+ - Example: `formats: [webp, avif]` → main file becomes `.webp`, variant is `.avif`
128
+
129
+ **When `replaceOriginal: false`**:
130
+ - The uploaded file stays in its original format.
131
+ - All configured formats are generated as separate variant files.
132
+
133
+ ## Fields Added to Collections
134
+
135
+ The plugin adds an `imageOptimizer` group field (read-only, displayed in the admin sidebar) to every configured collection:
136
+
137
+ ```ts
138
+ {
139
+ imageOptimizer: {
140
+ status: 'pending' | 'processing' | 'complete' | 'error',
141
+ error: string | null,
142
+ thumbHash: string | null, // base64-encoded ThumbHash
143
+ originalSize: number, // bytes
144
+ optimizedSize: number, // bytes
145
+ variants: [
146
+ {
147
+ format: string, // 'webp' | 'avif'
148
+ filename: string, // e.g. 'photo-optimized.avif'
149
+ filesize: number, // bytes
150
+ width: number,
151
+ height: number,
152
+ mimeType: string, // e.g. 'image/avif'
153
+ url: string, // e.g. '/media/photo-optimized.avif'
154
+ }
155
+ ]
156
+ }
157
+ }
158
+ ```
159
+
160
+ ## Admin UI
161
+
162
+ ### Optimization Status (Document Sidebar)
163
+
164
+ Every document in an optimized collection shows an `OptimizationStatus` component in the sidebar displaying:
165
+ - Color-coded status badge (pending/processing/complete/error)
166
+ - Original vs optimized file sizes with savings percentage
167
+ - ThumbHash preview thumbnail
168
+ - List of generated variants
169
+
170
+ ### Regenerate Images (Collection List View)
171
+
172
+ A `RegenerationButton` component is injected above the list table in every optimized collection:
173
+ - **"Regenerate Images"** button — queues optimization jobs for all images
174
+ - **"Force re-process all"** checkbox — re-optimizes already-complete images
175
+ - Live progress bar with polling (every 2 seconds)
176
+ - Stall detection — warns if processing stops progressing
177
+ - Persistent stats showing overall optimization status (e.g., "All 32 images optimized")
178
+
179
+ ## REST API Endpoints
180
+
181
+ ### `POST /api/image-optimizer/regenerate`
182
+
183
+ Queue bulk regeneration jobs for a collection. Requires authentication.
184
+
185
+ **Request body:**
186
+ ```json
187
+ {
188
+ "collectionSlug": "media",
189
+ "force": false
190
+ }
191
+ ```
192
+
193
+ **Response:**
194
+ ```json
195
+ {
196
+ "queued": 12,
197
+ "collectionSlug": "media"
198
+ }
199
+ ```
200
+
201
+ ### `GET /api/image-optimizer/regenerate?collection=media`
202
+
203
+ Get current optimization status for a collection. Requires authentication.
204
+
205
+ **Response:**
206
+ ```json
207
+ {
208
+ "collectionSlug": "media",
209
+ "total": 32,
210
+ "complete": 30,
211
+ "errored": 1,
212
+ "pending": 1
213
+ }
214
+ ```
215
+
216
+ ## Client-Side Utilities
217
+
218
+ Import from `@inoo-ch/payload-image-optimizer/client`:
219
+
220
+ ### `ImageBox` Component
221
+
222
+ Drop-in Next.js `<Image>` wrapper with automatic ThumbHash blur placeholders and focal point support.
223
+
224
+ ```tsx
225
+ import { ImageBox } from '@inoo-ch/payload-image-optimizer/client'
226
+
227
+ // With a Payload media resource object
228
+ <ImageBox media={doc.image} alt="Hero" fill sizes="100vw" />
229
+
230
+ // With a plain URL string
231
+ <ImageBox media="/images/fallback.jpg" alt="Fallback" width={800} height={600} />
232
+ ```
233
+
234
+ **Props:** Extends all Next.js `ImageProps` (except `src`), plus:
235
+
236
+ | Prop | Type | Description |
237
+ |------|------|-------------|
238
+ | `media` | `MediaResource \| string` | Payload media document or URL string |
239
+ | `alt` | `string` | Alt text (overrides `media.alt`) |
240
+
241
+ Automatically applies:
242
+ - ThumbHash blur placeholder (if available on the media resource)
243
+ - Focal point positioning via `objectPosition` (using `focalX`/`focalY`)
244
+ - Cache-busting via `updatedAt` query parameter
245
+ - `objectFit: 'cover'` by default (overridable via `style`)
246
+
247
+ ### `getImageOptimizerProps()` Utility
248
+
249
+ For integrating with existing image components (e.g., the Payload website template's `ImageMedia`):
250
+
251
+ ```tsx
252
+ import { getImageOptimizerProps } from '@inoo-ch/payload-image-optimizer/client'
253
+ import NextImage from 'next/image'
254
+
255
+ const optimizerProps = getImageOptimizerProps(media)
256
+
257
+ <NextImage
258
+ src={media.url}
259
+ alt={media.alt}
260
+ {...optimizerProps}
261
+ />
262
+ ```
263
+
264
+ **Returns:**
265
+ ```ts
266
+ {
267
+ placeholder: 'blur' | 'empty',
268
+ blurDataURL?: string, // data URL from ThumbHash (only when placeholder is 'blur')
269
+ style: {
270
+ objectPosition: string, // e.g. '50% 30%' from focalX/focalY, or 'center'
271
+ },
272
+ }
273
+ ```
274
+
275
+ ## Server-Side Utilities
276
+
277
+ Import from `@inoo-ch/payload-image-optimizer`:
278
+
279
+ ### `encodeImageToThumbHash(buffer, width, height)`
280
+
281
+ Encode raw RGBA pixel data to a base64 ThumbHash string.
282
+
283
+ ### `decodeThumbHashToDataURL(thumbHash)`
284
+
285
+ Decode a base64 ThumbHash string to a data URL for use as an `<img src>`.
286
+
287
+ ## Background Jobs
288
+
289
+ The plugin registers two Payload job tasks (retries: 2 each):
290
+
291
+ | Task Slug | Trigger | Purpose |
292
+ |-----------|---------|---------|
293
+ | `imageOptimizer_convertFormats` | After upload (`afterChange` hook) | Generate format variants for a single document |
294
+ | `imageOptimizer_regenerateDocument` | Bulk regeneration endpoint | Fully re-optimize a single document (resize + thumbhash + all variants) |
295
+
296
+ ## Full Example
297
+
298
+ ```ts
299
+ // payload.config.ts
300
+ import { buildConfig } from 'payload'
301
+ import { imageOptimizer } from '@inoo-ch/payload-image-optimizer'
302
+ import sharp from 'sharp'
303
+
304
+ export default buildConfig({
305
+ collections: [
306
+ {
307
+ slug: 'media',
308
+ fields: [],
309
+ upload: { staticDir: './media' },
310
+ },
311
+ {
312
+ slug: 'avatars',
313
+ fields: [],
314
+ upload: { staticDir: './avatars' },
315
+ },
316
+ ],
317
+ plugins: [
318
+ imageOptimizer({
319
+ collections: {
320
+ media: true, // all defaults: webp@80, 2560x2560, strip, thumbhash
321
+ avatars: {
322
+ maxDimensions: { width: 256, height: 256 },
323
+ formats: [{ format: 'webp', quality: 90 }],
324
+ },
325
+ },
326
+ formats: [
327
+ { format: 'webp', quality: 80 },
328
+ { format: 'avif', quality: 65 },
329
+ ],
330
+ }),
331
+ ],
332
+ sharp,
333
+ })
334
+ ```
335
+
336
+ ```tsx
337
+ // components/Hero.tsx
338
+ import { ImageBox } from '@inoo-ch/payload-image-optimizer/client'
339
+
340
+ export function Hero({ image }) {
341
+ return (
342
+ <div style={{ position: 'relative', height: '60vh' }}>
343
+ <ImageBox media={image} alt="Hero" fill sizes="100vw" priority />
344
+ </div>
345
+ )
346
+ }
347
+ ```
348
+
349
+ ## TypeScript
350
+
351
+ Exported types:
352
+
353
+ ```ts
354
+ import type {
355
+ ImageOptimizerConfig,
356
+ CollectionOptimizerConfig,
357
+ FormatQuality,
358
+ ImageFormat, // 'webp' | 'avif'
359
+ } from '@inoo-ch/payload-image-optimizer'
360
+
361
+ import type {
362
+ ImageBoxProps,
363
+ ImageOptimizerProps, // return type of getImageOptimizerProps
364
+ } from '@inoo-ch/payload-image-optimizer/client'
365
+ ```
366
+
367
+ ## Context Flags
368
+
369
+ The plugin uses `req.context` flags to control processing:
370
+
371
+ | Flag | Purpose |
372
+ |------|---------|
373
+ | `imageOptimizer_skip: true` | Set this on `req.context` to skip all optimization for a specific operation (useful for programmatic updates that shouldn't re-trigger processing). |
374
+
375
+ ```ts
376
+ // Example: update a media doc without re-processing
377
+ await payload.update({
378
+ collection: 'media',
379
+ id: docId,
380
+ data: { alt: 'Updated alt text' },
381
+ context: { imageOptimizer_skip: true },
382
+ })
383
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inoo-ch/payload-image-optimizer",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "Payload CMS plugin for automatic image optimization — WebP/AVIF conversion, resize, EXIF strip, ThumbHash placeholders, and bulk regeneration",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -24,44 +24,28 @@
24
24
  "type": "module",
25
25
  "exports": {
26
26
  ".": {
27
- "import": "./src/index.ts",
28
- "types": "./src/index.ts",
29
- "default": "./src/index.ts"
27
+ "import": "./dist/index.js",
28
+ "types": "./dist/index.d.ts",
29
+ "default": "./dist/index.js"
30
30
  },
31
31
  "./client": {
32
- "import": "./src/exports/client.ts",
33
- "types": "./src/exports/client.ts",
34
- "default": "./src/exports/client.ts"
32
+ "import": "./dist/exports/client.js",
33
+ "types": "./dist/exports/client.d.ts",
34
+ "default": "./dist/exports/client.js"
35
35
  },
36
36
  "./rsc": {
37
- "import": "./src/exports/rsc.ts",
38
- "types": "./src/exports/rsc.ts",
39
- "default": "./src/exports/rsc.ts"
37
+ "import": "./dist/exports/rsc.js",
38
+ "types": "./dist/exports/rsc.d.ts",
39
+ "default": "./dist/exports/rsc.js"
40
40
  }
41
41
  },
42
- "main": "./src/index.ts",
43
- "types": "./src/index.ts",
42
+ "main": "./dist/index.js",
43
+ "types": "./dist/index.d.ts",
44
44
  "files": [
45
- "dist"
45
+ "dist",
46
+ "src",
47
+ "AGENT_DOCS.md"
46
48
  ],
47
- "scripts": {
48
- "build": "pnpm copyfiles && pnpm build:types && pnpm build:swc",
49
- "build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths",
50
- "build:types": "tsc --outDir dist --rootDir ./src",
51
- "clean": "rimraf {dist,*.tsbuildinfo}",
52
- "copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png,json}\" dist/",
53
- "dev": "next dev dev --turbo",
54
- "dev:generate-importmap": "pnpm dev:payload generate:importmap",
55
- "dev:generate-types": "pnpm dev:payload generate:types",
56
- "dev:payload": "cross-env PAYLOAD_CONFIG_PATH=./dev/payload.config.ts payload",
57
- "generate:importmap": "pnpm dev:generate-importmap",
58
- "generate:types": "pnpm dev:generate-types",
59
- "lint": "eslint",
60
- "lint:fix": "eslint ./src --fix",
61
- "test": "pnpm test:int && pnpm test:e2e",
62
- "test:e2e": "playwright test",
63
- "test:int": "vitest"
64
- },
65
49
  "devDependencies": {
66
50
  "@eslint/eslintrc": "^3.2.0",
67
51
  "@payloadcms/db-mongodb": "3.79.0",
@@ -106,36 +90,26 @@
106
90
  "node": "^18.20.2 || >=20.9.0",
107
91
  "pnpm": "^9 || ^10"
108
92
  },
109
- "publishConfig": {
110
- "exports": {
111
- ".": {
112
- "import": "./dist/index.js",
113
- "types": "./dist/index.d.ts",
114
- "default": "./dist/index.js"
115
- },
116
- "./client": {
117
- "import": "./dist/exports/client.js",
118
- "types": "./dist/exports/client.d.ts",
119
- "default": "./dist/exports/client.js"
120
- },
121
- "./rsc": {
122
- "import": "./dist/exports/rsc.js",
123
- "types": "./dist/exports/rsc.d.ts",
124
- "default": "./dist/exports/rsc.js"
125
- }
126
- },
127
- "main": "./dist/index.js",
128
- "types": "./dist/index.d.ts"
129
- },
130
- "pnpm": {
131
- "onlyBuiltDependencies": [
132
- "sharp",
133
- "esbuild",
134
- "unrs-resolver"
135
- ]
136
- },
137
93
  "registry": "https://registry.npmjs.org/",
138
94
  "dependencies": {
139
95
  "thumbhash": "^0.1.1"
96
+ },
97
+ "scripts": {
98
+ "build": "pnpm copyfiles && pnpm build:types && pnpm build:swc",
99
+ "build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths",
100
+ "build:types": "tsc --outDir dist --rootDir ./src",
101
+ "clean": "rimraf {dist,*.tsbuildinfo}",
102
+ "copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png,json}\" dist/",
103
+ "dev": "next dev dev --turbo",
104
+ "dev:generate-importmap": "pnpm dev:payload generate:importmap",
105
+ "dev:generate-types": "pnpm dev:payload generate:types",
106
+ "dev:payload": "cross-env PAYLOAD_CONFIG_PATH=./dev/payload.config.ts payload",
107
+ "generate:importmap": "pnpm dev:generate-importmap",
108
+ "generate:types": "pnpm dev:generate-types",
109
+ "lint": "eslint",
110
+ "lint:fix": "eslint ./src --fix",
111
+ "test": "pnpm test:int && pnpm test:e2e",
112
+ "test:e2e": "playwright test",
113
+ "test:int": "vitest"
140
114
  }
141
- }
115
+ }
@@ -0,0 +1,80 @@
1
+ 'use client'
2
+
3
+ import React from 'react'
4
+ import NextImage, { type ImageProps } from 'next/image'
5
+ import { getImageOptimizerProps } from '../utilities/getImageOptimizerProps.js'
6
+
7
+ type ImageOptimizerData = {
8
+ thumbHash?: string | null
9
+ }
10
+
11
+ type MediaResource = {
12
+ url?: string | null
13
+ alt?: string | null
14
+ width?: number | null
15
+ height?: number | null
16
+ filename?: string | null
17
+ focalX?: number | null
18
+ focalY?: number | null
19
+ imageOptimizer?: ImageOptimizerData | null
20
+ updatedAt?: string
21
+ }
22
+
23
+ export interface ImageBoxProps extends Omit<ImageProps, 'src' | 'alt'> {
24
+ media: MediaResource | string
25
+ alt?: string
26
+ }
27
+
28
+ export const ImageBox: React.FC<ImageBoxProps> = ({
29
+ media,
30
+ alt: altFromProps,
31
+ fill,
32
+ sizes,
33
+ priority,
34
+ loading: loadingFromProps,
35
+ style: styleFromProps,
36
+ ...props
37
+ }) => {
38
+ const loading = priority ? undefined : (loadingFromProps ?? 'lazy')
39
+
40
+ if (typeof media === 'string') {
41
+ return (
42
+ <NextImage
43
+ {...props}
44
+ src={media}
45
+ alt={altFromProps || ''}
46
+ quality={80}
47
+ fill={fill}
48
+ sizes={sizes}
49
+ style={{ objectFit: 'cover', objectPosition: 'center', ...styleFromProps }}
50
+ priority={priority}
51
+ loading={loading}
52
+ />
53
+ )
54
+ }
55
+
56
+ const width = media.width ?? undefined
57
+ const height = media.height ?? undefined
58
+ const alt = altFromProps || (media as any).alt || media.filename || ''
59
+ const src = media.url ? `${media.url}${media.updatedAt ? `?${media.updatedAt}` : ''}` : ''
60
+
61
+ const optimizerProps = getImageOptimizerProps(media)
62
+
63
+ return (
64
+ <NextImage
65
+ {...props}
66
+ src={src}
67
+ alt={alt}
68
+ quality={80}
69
+ fill={fill}
70
+ width={!fill ? width : undefined}
71
+ height={!fill ? height : undefined}
72
+ sizes={sizes}
73
+ style={{ objectFit: 'cover', ...optimizerProps.style, ...styleFromProps }}
74
+ placeholder={optimizerProps.placeholder}
75
+ blurDataURL={optimizerProps.blurDataURL}
76
+ priority={priority}
77
+ loading={loading}
78
+ />
79
+ )
80
+ }