@inoo-ch/payload-image-optimizer 1.4.7 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENT_DOCS.md CHANGED
@@ -59,6 +59,7 @@ imageOptimizer({
59
59
  stripMetadata: true, // strip EXIF data
60
60
  generateThumbHash: true, // generate blur placeholders
61
61
  replaceOriginal: true, // convert main file to primary format
62
+ clientOptimization: false, // pre-resize in browser before upload
62
63
  disabled: false, // keep fields but skip all processing
63
64
  })
64
65
  ```
@@ -71,6 +72,7 @@ imageOptimizer({
71
72
  | `stripMetadata` | `boolean` | `true` | Strip EXIF, ICC, XMP metadata. |
72
73
  | `generateThumbHash` | `boolean` | `true` | Generate ThumbHash blur placeholder. |
73
74
  | `replaceOriginal` | `boolean` | `true` | Replace the original file with the primary format. |
75
+ | `clientOptimization` | `boolean` | `false` | Pre-resize images in browser via Canvas API before upload. Reduces upload size 90%+ for large images. |
74
76
  | `disabled` | `boolean` | `false` | Keep schema fields but disable all processing. |
75
77
 
76
78
  ### Per-Collection Overrides (`CollectionOptimizerConfig`)
@@ -327,6 +329,29 @@ The plugin registers two Payload job tasks (retries: 2 each):
327
329
  | `imageOptimizer_convertFormats` | After upload (`afterChange` hook) | Generate format variants for a single document |
328
330
  | `imageOptimizer_regenerateDocument` | Bulk regeneration endpoint | Fully re-optimize a single document (resize + thumbhash + all variants) |
329
331
 
332
+ ## Vercel / Serverless Deployment
333
+
334
+ Image processing can exceed the default serverless function timeout. Re-export the plugin's `maxDuration` from the Payload API route:
335
+
336
+ ```ts
337
+ // src/app/(payload)/api/[...slug]/route.ts
338
+ export { maxDuration } from '@inoo-ch/payload-image-optimizer'
339
+ ```
340
+
341
+ This sets a 60-second timeout. Without this, uploads with heavy configs (AVIF + ThumbHash + metadata stripping) may time out on Vercel.
342
+
343
+ ### Large file uploads with Vercel Blob
344
+
345
+ Even with `maxDuration` and `bodySizeLimit`, large uploads hit Vercel's 4.5MB request body limit on serverless functions. If using `@payloadcms/storage-vercel-blob`, enable `clientUploads: true` so files upload directly from the browser to Vercel Blob (up to 5TB), bypassing the server body size limit entirely:
346
+
347
+ ```ts
348
+ vercelBlobStorage({
349
+ collections: { media: true },
350
+ token: process.env.BLOB_READ_WRITE_TOKEN,
351
+ clientUploads: true,
352
+ })
353
+ ```
354
+
330
355
  ## Full Example
331
356
 
332
357
  ```ts
package/README.md CHANGED
@@ -86,6 +86,7 @@ imageOptimizer({
86
86
  maxDimensions: { width: 2560, height: 2560 },
87
87
  generateThumbHash: true,
88
88
  stripMetadata: true,
89
+ clientOptimization: false,
89
90
  disabled: false,
90
91
  })
91
92
  ```
@@ -99,6 +100,7 @@ imageOptimizer({
99
100
  | `maxDimensions` | `{ width: number, height: number }` | `{ width: 2560, height: 2560 }` | Maximum image dimensions. Images are resized to fit within these bounds. |
100
101
  | `generateThumbHash` | `boolean` | `true` | Generate ThumbHash blur placeholders for instant image previews. |
101
102
  | `stripMetadata` | `boolean` | `true` | Remove EXIF and other metadata from images. |
103
+ | `clientOptimization` | `boolean` | `false` | Pre-resize images in the browser before upload using Canvas API. Reduces upload size by up to 90% for large images. |
102
104
  | `disabled` | `boolean` | `false` | Disable optimization while keeping schema fields intact. |
103
105
 
104
106
  ### Per-Collection Overrides
@@ -123,6 +125,27 @@ collections: {
123
125
  }
124
126
  ```
125
127
 
128
+ ### Client-Side Optimization
129
+
130
+ When `clientOptimization: true` is set, images are pre-resized in the browser before uploading. This uses the Canvas API (zero additional dependencies) to shrink large images to fit within `maxDimensions` before they enter the upload pipeline.
131
+
132
+ ```ts
133
+ imageOptimizer({
134
+ clientOptimization: true,
135
+ collections: { media: true },
136
+ })
137
+ ```
138
+
139
+ **How it helps:**
140
+ - A 12MB DSLR photo is resized to ~100-500KB *before* upload — 90%+ less data transferred
141
+ - Especially important with cloud storage + `clientUploads: true`, where files round-trip through blob storage
142
+ - Reduces serverless function processing time (smaller input = faster sharp conversion)
143
+ - EXIF metadata is stripped automatically (Canvas output has no metadata)
144
+
145
+ **What stays server-side:** Format conversion (WebP/AVIF), ThumbHash generation, and variant creation still happen on the server with sharp for quality consistency. The client only handles resize — the highest-impact optimization with zero quality trade-off.
146
+
147
+ **Limitations:** Only applies to single-file uploads in the admin panel. Bulk uploads and API/programmatic uploads are processed server-side as usual.
148
+
126
149
  ## How It Works
127
150
 
128
151
  1. **Upload** — An image is uploaded to a configured collection
@@ -133,6 +156,31 @@ collections: {
133
156
 
134
157
  All format conversion runs as async background jobs, so uploads return immediately.
135
158
 
159
+ ### Vercel / Serverless Deployment
160
+
161
+ Image processing (especially AVIF encoding, ThumbHash generation, and metadata stripping) can exceed the default serverless function timeout. The plugin exports a recommended `maxDuration` that you can re-export from your Payload API route:
162
+
163
+ ```ts
164
+ // src/app/(payload)/api/[...slug]/route.ts
165
+ export { maxDuration } from '@inoo-ch/payload-image-optimizer'
166
+ ```
167
+
168
+ This sets a 60-second timeout, which is sufficient for most configurations. Without this, heavy processing configs may cause upload timeouts on Vercel.
169
+
170
+ #### Large file uploads with Vercel Blob
171
+
172
+ Even with `maxDuration` and `bodySizeLimit` configured, large file uploads through the Payload admin still go through the Next.js API route, which hits Vercel's request body size limit (4.5MB on serverless functions). If you're using `@payloadcms/storage-vercel-blob`, enable `clientUploads` to bypass this entirely:
173
+
174
+ ```ts
175
+ vercelBlobStorage({
176
+ collections: { media: true },
177
+ token: process.env.BLOB_READ_WRITE_TOKEN,
178
+ clientUploads: true, // uploads go directly from browser to Vercel Blob
179
+ })
180
+ ```
181
+
182
+ 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
+
136
184
  ## How It Differs from Payload's Default Image Handling
137
185
 
138
186
  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.
@@ -0,0 +1,2 @@
1
+ import React from 'react';
2
+ export declare const UploadOptimizer: React.FC;
@@ -0,0 +1,84 @@
1
+ 'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import React, { useCallback, useEffect, useRef } from 'react';
4
+ import { Upload, useDocumentInfo, useField } from '@payloadcms/ui';
5
+ const MAX_WIDTH = 2560;
6
+ const MAX_HEIGHT = 2560;
7
+ const JPEG_QUALITY = 0.85;
8
+ const RESIZABLE_TYPES = new Set([
9
+ 'image/jpeg',
10
+ 'image/png',
11
+ 'image/webp',
12
+ 'image/bmp',
13
+ 'image/tiff'
14
+ ]);
15
+ async function resizeImage(file) {
16
+ if (!RESIZABLE_TYPES.has(file.type)) return file;
17
+ const bitmap = await createImageBitmap(file);
18
+ const { width, height } = bitmap;
19
+ if (width <= MAX_WIDTH && height <= MAX_HEIGHT) {
20
+ bitmap.close();
21
+ return file;
22
+ }
23
+ const ratio = Math.min(MAX_WIDTH / width, MAX_HEIGHT / height);
24
+ const targetWidth = Math.round(width * ratio);
25
+ const targetHeight = Math.round(height * ratio);
26
+ const canvas = document.createElement('canvas');
27
+ canvas.width = targetWidth;
28
+ canvas.height = targetHeight;
29
+ const ctx = canvas.getContext('2d');
30
+ ctx.drawImage(bitmap, 0, 0, targetWidth, targetHeight);
31
+ bitmap.close();
32
+ // Keep PNG for transparency, convert everything else to JPEG
33
+ const outputType = file.type === 'image/png' ? 'image/png' : 'image/jpeg';
34
+ const quality = outputType === 'image/jpeg' ? JPEG_QUALITY : undefined;
35
+ const ext = outputType === 'image/png' ? 'png' : 'jpg';
36
+ const blob = await new Promise((resolve)=>{
37
+ canvas.toBlob((b)=>resolve(b), outputType, quality);
38
+ });
39
+ const baseName = file.name.replace(/\.[^.]+$/, '');
40
+ return new File([
41
+ blob
42
+ ], `${baseName}.${ext}`, {
43
+ type: outputType,
44
+ lastModified: Date.now()
45
+ });
46
+ }
47
+ export const UploadOptimizer = ()=>{
48
+ const { collectionSlug, docConfig, initialState } = useDocumentInfo();
49
+ const uploadConfig = docConfig && 'upload' in docConfig ? docConfig.upload : undefined;
50
+ const { value: fileValue, setValue: setFileValue } = useField({
51
+ path: 'file'
52
+ });
53
+ const processedFiles = useRef(new WeakSet());
54
+ const handleResize = useCallback(async (file)=>{
55
+ return resizeImage(file);
56
+ }, []);
57
+ useEffect(()=>{
58
+ if (!fileValue || !(fileValue instanceof File)) return;
59
+ if (processedFiles.current.has(fileValue)) return;
60
+ let cancelled = false;
61
+ handleResize(fileValue).then((resized)=>{
62
+ if (cancelled) return;
63
+ processedFiles.current.add(resized);
64
+ if (resized !== fileValue) {
65
+ setFileValue(resized);
66
+ }
67
+ });
68
+ return ()=>{
69
+ cancelled = true;
70
+ };
71
+ }, [
72
+ fileValue,
73
+ handleResize,
74
+ setFileValue
75
+ ]);
76
+ if (!collectionSlug || !uploadConfig) return null;
77
+ return /*#__PURE__*/ _jsx(Upload, {
78
+ collectionSlug: collectionSlug,
79
+ initialState: initialState,
80
+ uploadConfig: uploadConfig
81
+ });
82
+ };
83
+
84
+ //# sourceMappingURL=UploadOptimizer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/components/UploadOptimizer.tsx"],"sourcesContent":["'use client'\n\nimport React, { useCallback, useEffect, useRef } from 'react'\nimport { Upload, useDocumentInfo, useField } from '@payloadcms/ui'\n\nconst MAX_WIDTH = 2560\nconst MAX_HEIGHT = 2560\nconst JPEG_QUALITY = 0.85\n\nconst RESIZABLE_TYPES = new Set([\n 'image/jpeg',\n 'image/png',\n 'image/webp',\n 'image/bmp',\n 'image/tiff',\n])\n\nasync function resizeImage(file: File): Promise<File> {\n if (!RESIZABLE_TYPES.has(file.type)) return file\n\n const bitmap = await createImageBitmap(file)\n const { width, height } = bitmap\n\n if (width <= MAX_WIDTH && height <= MAX_HEIGHT) {\n bitmap.close()\n return file\n }\n\n const ratio = Math.min(MAX_WIDTH / width, MAX_HEIGHT / height)\n const targetWidth = Math.round(width * ratio)\n const targetHeight = Math.round(height * ratio)\n\n const canvas = document.createElement('canvas')\n canvas.width = targetWidth\n canvas.height = targetHeight\n const ctx = canvas.getContext('2d')!\n ctx.drawImage(bitmap, 0, 0, targetWidth, targetHeight)\n bitmap.close()\n\n // Keep PNG for transparency, convert everything else to JPEG\n const outputType = file.type === 'image/png' ? 'image/png' : 'image/jpeg'\n const quality = outputType === 'image/jpeg' ? JPEG_QUALITY : undefined\n const ext = outputType === 'image/png' ? 'png' : 'jpg'\n\n const blob = await new Promise<Blob>((resolve) => {\n canvas.toBlob((b) => resolve(b!), outputType, quality)\n })\n\n const baseName = file.name.replace(/\\.[^.]+$/, '')\n return new File([blob], `${baseName}.${ext}`, {\n type: outputType,\n lastModified: Date.now(),\n })\n}\n\nexport const UploadOptimizer: React.FC = () => {\n const { collectionSlug, docConfig, initialState } = useDocumentInfo()\n const uploadConfig = docConfig && 'upload' in docConfig ? docConfig.upload : undefined\n const { value: fileValue, setValue: setFileValue } = useField<File | null>({ path: 'file' })\n const processedFiles = useRef(new WeakSet<File>())\n\n const handleResize = useCallback(async (file: File): Promise<File> => {\n return resizeImage(file)\n }, [])\n\n useEffect(() => {\n if (!fileValue || !(fileValue instanceof File)) return\n if (processedFiles.current.has(fileValue)) return\n\n let cancelled = false\n\n handleResize(fileValue).then((resized) => {\n if (cancelled) return\n processedFiles.current.add(resized)\n if (resized !== fileValue) {\n setFileValue(resized)\n }\n })\n\n return () => {\n cancelled = true\n }\n }, [fileValue, handleResize, setFileValue])\n\n if (!collectionSlug || !uploadConfig) return null\n\n return (\n <Upload\n collectionSlug={collectionSlug}\n initialState={initialState}\n uploadConfig={uploadConfig}\n />\n )\n}\n"],"names":["React","useCallback","useEffect","useRef","Upload","useDocumentInfo","useField","MAX_WIDTH","MAX_HEIGHT","JPEG_QUALITY","RESIZABLE_TYPES","Set","resizeImage","file","has","type","bitmap","createImageBitmap","width","height","close","ratio","Math","min","targetWidth","round","targetHeight","canvas","document","createElement","ctx","getContext","drawImage","outputType","quality","undefined","ext","blob","Promise","resolve","toBlob","b","baseName","name","replace","File","lastModified","Date","now","UploadOptimizer","collectionSlug","docConfig","initialState","uploadConfig","upload","value","fileValue","setValue","setFileValue","path","processedFiles","WeakSet","handleResize","current","cancelled","then","resized","add"],"mappings":"AAAA;;AAEA,OAAOA,SAASC,WAAW,EAAEC,SAAS,EAAEC,MAAM,QAAQ,QAAO;AAC7D,SAASC,MAAM,EAAEC,eAAe,EAAEC,QAAQ,QAAQ,iBAAgB;AAElE,MAAMC,YAAY;AAClB,MAAMC,aAAa;AACnB,MAAMC,eAAe;AAErB,MAAMC,kBAAkB,IAAIC,IAAI;IAC9B;IACA;IACA;IACA;IACA;CACD;AAED,eAAeC,YAAYC,IAAU;IACnC,IAAI,CAACH,gBAAgBI,GAAG,CAACD,KAAKE,IAAI,GAAG,OAAOF;IAE5C,MAAMG,SAAS,MAAMC,kBAAkBJ;IACvC,MAAM,EAAEK,KAAK,EAAEC,MAAM,EAAE,GAAGH;IAE1B,IAAIE,SAASX,aAAaY,UAAUX,YAAY;QAC9CQ,OAAOI,KAAK;QACZ,OAAOP;IACT;IAEA,MAAMQ,QAAQC,KAAKC,GAAG,CAAChB,YAAYW,OAAOV,aAAaW;IACvD,MAAMK,cAAcF,KAAKG,KAAK,CAACP,QAAQG;IACvC,MAAMK,eAAeJ,KAAKG,KAAK,CAACN,SAASE;IAEzC,MAAMM,SAASC,SAASC,aAAa,CAAC;IACtCF,OAAOT,KAAK,GAAGM;IACfG,OAAOR,MAAM,GAAGO;IAChB,MAAMI,MAAMH,OAAOI,UAAU,CAAC;IAC9BD,IAAIE,SAAS,CAAChB,QAAQ,GAAG,GAAGQ,aAAaE;IACzCV,OAAOI,KAAK;IAEZ,6DAA6D;IAC7D,MAAMa,aAAapB,KAAKE,IAAI,KAAK,cAAc,cAAc;IAC7D,MAAMmB,UAAUD,eAAe,eAAexB,eAAe0B;IAC7D,MAAMC,MAAMH,eAAe,cAAc,QAAQ;IAEjD,MAAMI,OAAO,MAAM,IAAIC,QAAc,CAACC;QACpCZ,OAAOa,MAAM,CAAC,CAACC,IAAMF,QAAQE,IAAKR,YAAYC;IAChD;IAEA,MAAMQ,WAAW7B,KAAK8B,IAAI,CAACC,OAAO,CAAC,YAAY;IAC/C,OAAO,IAAIC,KAAK;QAACR;KAAK,EAAE,GAAGK,SAAS,CAAC,EAAEN,KAAK,EAAE;QAC5CrB,MAAMkB;QACNa,cAAcC,KAAKC,GAAG;IACxB;AACF;AAEA,OAAO,MAAMC,kBAA4B;IACvC,MAAM,EAAEC,cAAc,EAAEC,SAAS,EAAEC,YAAY,EAAE,GAAG/C;IACpD,MAAMgD,eAAeF,aAAa,YAAYA,YAAYA,UAAUG,MAAM,GAAGnB;IAC7E,MAAM,EAAEoB,OAAOC,SAAS,EAAEC,UAAUC,YAAY,EAAE,GAAGpD,SAAsB;QAAEqD,MAAM;IAAO;IAC1F,MAAMC,iBAAiBzD,OAAO,IAAI0D;IAElC,MAAMC,eAAe7D,YAAY,OAAOY;QACtC,OAAOD,YAAYC;IACrB,GAAG,EAAE;IAELX,UAAU;QACR,IAAI,CAACsD,aAAa,CAAEA,CAAAA,qBAAqBX,IAAG,GAAI;QAChD,IAAIe,eAAeG,OAAO,CAACjD,GAAG,CAAC0C,YAAY;QAE3C,IAAIQ,YAAY;QAEhBF,aAAaN,WAAWS,IAAI,CAAC,CAACC;YAC5B,IAAIF,WAAW;YACfJ,eAAeG,OAAO,CAACI,GAAG,CAACD;YAC3B,IAAIA,YAAYV,WAAW;gBACzBE,aAAaQ;YACf;QACF;QAEA,OAAO;YACLF,YAAY;QACd;IACF,GAAG;QAACR;QAAWM;QAAcJ;KAAa;IAE1C,IAAI,CAACR,kBAAkB,CAACG,cAAc,OAAO;IAE7C,qBACE,KAACjD;QACC8C,gBAAgBA;QAChBE,cAAcA;QACdC,cAAcA;;AAGpB,EAAC"}
package/dist/defaults.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export const resolveConfig = (config)=>({
2
+ clientOptimization: config.clientOptimization ?? false,
2
3
  collections: config.collections,
3
4
  disabled: config.disabled ?? false,
4
5
  formats: config.formats ?? [
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/defaults.ts"],"sourcesContent":["import type { CollectionSlug } from 'payload'\n\nimport type { ImageOptimizerConfig, ResolvedCollectionOptimizerConfig, ResolvedImageOptimizerConfig } from './types.js'\n\nexport const resolveConfig = (config: ImageOptimizerConfig): ResolvedImageOptimizerConfig => ({\n collections: config.collections,\n disabled: config.disabled ?? false,\n formats: config.formats ?? [\n { format: 'webp', quality: 80 },\n ],\n generateThumbHash: config.generateThumbHash ?? true,\n maxDimensions: config.maxDimensions ?? { width: 2560, height: 2560 },\n replaceOriginal: config.replaceOriginal ?? true,\n stripMetadata: config.stripMetadata ?? true,\n})\n\nexport const resolveCollectionConfig = (\n resolvedConfig: ResolvedImageOptimizerConfig,\n collectionSlug: string,\n): ResolvedCollectionOptimizerConfig => {\n const collectionValue = resolvedConfig.collections[collectionSlug as CollectionSlug]\n\n if (!collectionValue || collectionValue === true) {\n return {\n formats: resolvedConfig.formats,\n maxDimensions: resolvedConfig.maxDimensions,\n replaceOriginal: resolvedConfig.replaceOriginal,\n }\n }\n\n return {\n formats: collectionValue.formats ?? resolvedConfig.formats,\n maxDimensions: collectionValue.maxDimensions ?? resolvedConfig.maxDimensions,\n replaceOriginal: collectionValue.replaceOriginal ?? resolvedConfig.replaceOriginal,\n }\n}\n"],"names":["resolveConfig","config","collections","disabled","formats","format","quality","generateThumbHash","maxDimensions","width","height","replaceOriginal","stripMetadata","resolveCollectionConfig","resolvedConfig","collectionSlug","collectionValue"],"mappings":"AAIA,OAAO,MAAMA,gBAAgB,CAACC,SAAgE,CAAA;QAC5FC,aAAaD,OAAOC,WAAW;QAC/BC,UAAUF,OAAOE,QAAQ,IAAI;QAC7BC,SAASH,OAAOG,OAAO,IAAI;YACzB;gBAAEC,QAAQ;gBAAQC,SAAS;YAAG;SAC/B;QACDC,mBAAmBN,OAAOM,iBAAiB,IAAI;QAC/CC,eAAeP,OAAOO,aAAa,IAAI;YAAEC,OAAO;YAAMC,QAAQ;QAAK;QACnEC,iBAAiBV,OAAOU,eAAe,IAAI;QAC3CC,eAAeX,OAAOW,aAAa,IAAI;IACzC,CAAA,EAAE;AAEF,OAAO,MAAMC,0BAA0B,CACrCC,gBACAC;IAEA,MAAMC,kBAAkBF,eAAeZ,WAAW,CAACa,eAAiC;IAEpF,IAAI,CAACC,mBAAmBA,oBAAoB,MAAM;QAChD,OAAO;YACLZ,SAASU,eAAeV,OAAO;YAC/BI,eAAeM,eAAeN,aAAa;YAC3CG,iBAAiBG,eAAeH,eAAe;QACjD;IACF;IAEA,OAAO;QACLP,SAASY,gBAAgBZ,OAAO,IAAIU,eAAeV,OAAO;QAC1DI,eAAeQ,gBAAgBR,aAAa,IAAIM,eAAeN,aAAa;QAC5EG,iBAAiBK,gBAAgBL,eAAe,IAAIG,eAAeH,eAAe;IACpF;AACF,EAAC"}
1
+ {"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 ?? false,\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"}
@@ -6,3 +6,4 @@ 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
8
  export { RegenerationButton } from '../components/RegenerationButton.js';
9
+ export { UploadOptimizer } from '../components/UploadOptimizer.js';
@@ -3,5 +3,6 @@ export { ImageBox } from '../components/ImageBox.js';
3
3
  export { FadeImage } from '../components/FadeImage.js';
4
4
  export { getImageOptimizerProps } from '../utilities/getImageOptimizerProps.js';
5
5
  export { RegenerationButton } from '../components/RegenerationButton.js';
6
+ export { UploadOptimizer } from '../components/UploadOptimizer.js';
6
7
 
7
8
  //# sourceMappingURL=client.js.map
@@ -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'\n"],"names":["OptimizationStatus","ImageBox","FadeImage","getImageOptimizerProps","RegenerationButton"],"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"}
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"}
package/dist/index.d.ts CHANGED
@@ -3,4 +3,11 @@ import type { ImageOptimizerConfig } from './types.js';
3
3
  export type { ImageOptimizerConfig, ImageFormat, FormatQuality, CollectionOptimizerConfig, ImageOptimizerData, MediaResource, FieldsOverride } from './types.js';
4
4
  export { defaultImageOptimizerFields } from './fields/imageOptimizerField.js';
5
5
  export { encodeImageToThumbHash, decodeThumbHashToDataURL } from './utilities/thumbhash.js';
6
+ /**
7
+ * Recommended maxDuration for the Payload API route on Vercel.
8
+ * Re-export this in your route file:
9
+ *
10
+ * export { maxDuration } from '@inoo-ch/payload-image-optimizer'
11
+ */
12
+ export declare const maxDuration = 60;
6
13
  export declare const imageOptimizer: (pluginOptions: ImageOptimizerConfig) => (config: Config) => Config;
package/dist/index.js CHANGED
@@ -9,6 +9,12 @@ import { createRegenerateDocumentHandler } from './tasks/regenerateDocument.js';
9
9
  import { createRegenerateHandler, createRegenerateStatusHandler } from './endpoints/regenerate.js';
10
10
  export { defaultImageOptimizerFields } from './fields/imageOptimizerField.js';
11
11
  export { encodeImageToThumbHash, decodeThumbHashToDataURL } from './utilities/thumbhash.js';
12
+ /**
13
+ * Recommended maxDuration for the Payload API route on Vercel.
14
+ * Re-export this in your route file:
15
+ *
16
+ * export { maxDuration } from '@inoo-ch/payload-image-optimizer'
17
+ */ export const maxDuration = 60;
12
18
  export const imageOptimizer = (pluginOptions)=>(config)=>{
13
19
  const resolvedConfig = resolveConfig(pluginOptions);
14
20
  const targetSlugs = Object.keys(resolvedConfig.collections);
@@ -46,6 +52,12 @@ export const imageOptimizer = (pluginOptions)=>(config)=>{
46
52
  ...collection.admin,
47
53
  components: {
48
54
  ...collection.admin?.components,
55
+ ...resolvedConfig.clientOptimization && !collection.admin?.components?.edit?.Upload ? {
56
+ edit: {
57
+ ...collection.admin?.components?.edit,
58
+ Upload: '@inoo-ch/payload-image-optimizer/client#UploadOptimizer'
59
+ }
60
+ } : {},
49
61
  beforeListTable: [
50
62
  ...collection.admin?.components?.beforeListTable || [],
51
63
  '@inoo-ch/payload-image-optimizer/client#RegenerationButton'
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"sourcesContent":["import type { Config } from 'payload'\nimport { deepMergeSimple } from 'payload/shared'\n\nimport type { ImageOptimizerConfig } from './types.js'\nimport { resolveConfig } from './defaults.js'\nimport { translations } from './translations/index.js'\nimport { getImageOptimizerField } from './fields/imageOptimizerField.js'\nimport { createBeforeChangeHook } from './hooks/beforeChange.js'\nimport { createAfterChangeHook } from './hooks/afterChange.js'\nimport { createConvertFormatsHandler } from './tasks/convertFormats.js'\nimport { createRegenerateDocumentHandler } from './tasks/regenerateDocument.js'\nimport { createRegenerateHandler, createRegenerateStatusHandler } from './endpoints/regenerate.js'\n\nexport type { ImageOptimizerConfig, ImageFormat, FormatQuality, CollectionOptimizerConfig, ImageOptimizerData, MediaResource, FieldsOverride } from './types.js'\nexport { defaultImageOptimizerFields } from './fields/imageOptimizerField.js'\n\nexport { encodeImageToThumbHash, decodeThumbHashToDataURL } from './utilities/thumbhash.js'\n\nexport const imageOptimizer =\n (pluginOptions: ImageOptimizerConfig) =>\n (config: Config): Config => {\n const resolvedConfig = resolveConfig(pluginOptions)\n const targetSlugs = Object.keys(resolvedConfig.collections)\n\n // Inject fields (and hooks when enabled) into targeted upload collections\n const collections = (config.collections || []).map((collection) => {\n if (!targetSlugs.includes(collection.slug)) {\n return collection\n }\n\n // Always inject fields for schema consistency (even when disabled)\n const fields = [...collection.fields, getImageOptimizerField(pluginOptions.fieldsOverride)]\n\n if (resolvedConfig.disabled) {\n return { ...collection, fields }\n }\n\n return {\n ...collection,\n fields,\n hooks: {\n ...collection.hooks,\n beforeChange: [\n ...(collection.hooks?.beforeChange || []),\n createBeforeChangeHook(resolvedConfig, collection.slug),\n ],\n afterChange: [\n ...(collection.hooks?.afterChange || []),\n createAfterChangeHook(resolvedConfig, collection.slug),\n ],\n },\n admin: {\n ...collection.admin,\n components: {\n ...collection.admin?.components,\n beforeListTable: [\n ...(collection.admin?.components?.beforeListTable || []),\n '@inoo-ch/payload-image-optimizer/client#RegenerationButton',\n ],\n },\n },\n }\n })\n\n const i18n = {\n ...config.i18n,\n translations: deepMergeSimple(translations, config.i18n?.translations ?? {}),\n }\n\n // If disabled, return with fields injected but no tasks/endpoints\n if (resolvedConfig.disabled) {\n return { ...config, collections, i18n }\n }\n\n return {\n ...config,\n collections,\n i18n,\n jobs: {\n ...config.jobs,\n tasks: [\n ...(config.jobs?.tasks || []),\n {\n slug: 'imageOptimizer_convertFormats',\n inputSchema: [\n { name: 'collectionSlug', type: 'text', required: true },\n { name: 'docId', type: 'text', required: true },\n ],\n outputSchema: [\n { name: 'variantsGenerated', type: 'number' },\n ],\n retries: 2,\n handler: createConvertFormatsHandler(resolvedConfig),\n } as any,\n {\n slug: 'imageOptimizer_regenerateDocument',\n inputSchema: [\n { name: 'collectionSlug', type: 'text', required: true },\n { name: 'docId', type: 'text', required: true },\n ],\n outputSchema: [\n { name: 'status', type: 'text' },\n { name: 'reason', type: 'text' },\n ],\n retries: 2,\n handler: createRegenerateDocumentHandler(resolvedConfig),\n } as any,\n ],\n },\n endpoints: [\n ...(config.endpoints ?? []),\n {\n path: '/image-optimizer/regenerate',\n method: 'post',\n handler: createRegenerateHandler(resolvedConfig),\n },\n {\n path: '/image-optimizer/regenerate',\n method: 'get',\n handler: createRegenerateStatusHandler(resolvedConfig),\n },\n ],\n }\n }\n"],"names":["deepMergeSimple","resolveConfig","translations","getImageOptimizerField","createBeforeChangeHook","createAfterChangeHook","createConvertFormatsHandler","createRegenerateDocumentHandler","createRegenerateHandler","createRegenerateStatusHandler","defaultImageOptimizerFields","encodeImageToThumbHash","decodeThumbHashToDataURL","imageOptimizer","pluginOptions","config","resolvedConfig","targetSlugs","Object","keys","collections","map","collection","includes","slug","fields","fieldsOverride","disabled","hooks","beforeChange","afterChange","admin","components","beforeListTable","i18n","jobs","tasks","inputSchema","name","type","required","outputSchema","retries","handler","endpoints","path","method"],"mappings":"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"}
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["import type { Config } from 'payload'\nimport { deepMergeSimple } from 'payload/shared'\n\nimport type { ImageOptimizerConfig } from './types.js'\nimport { resolveConfig } from './defaults.js'\nimport { translations } from './translations/index.js'\nimport { getImageOptimizerField } from './fields/imageOptimizerField.js'\nimport { createBeforeChangeHook } from './hooks/beforeChange.js'\nimport { createAfterChangeHook } from './hooks/afterChange.js'\nimport { createConvertFormatsHandler } from './tasks/convertFormats.js'\nimport { createRegenerateDocumentHandler } from './tasks/regenerateDocument.js'\nimport { createRegenerateHandler, createRegenerateStatusHandler } from './endpoints/regenerate.js'\n\nexport type { ImageOptimizerConfig, ImageFormat, FormatQuality, CollectionOptimizerConfig, ImageOptimizerData, MediaResource, FieldsOverride } from './types.js'\nexport { defaultImageOptimizerFields } from './fields/imageOptimizerField.js'\n\nexport { encodeImageToThumbHash, decodeThumbHashToDataURL } from './utilities/thumbhash.js'\n\n/**\n * Recommended maxDuration for the Payload API route on Vercel.\n * Re-export this in your route file:\n *\n * export { maxDuration } from '@inoo-ch/payload-image-optimizer'\n */\nexport const maxDuration = 60\n\nexport const imageOptimizer =\n (pluginOptions: ImageOptimizerConfig) =>\n (config: Config): Config => {\n const resolvedConfig = resolveConfig(pluginOptions)\n const targetSlugs = Object.keys(resolvedConfig.collections)\n\n // Inject fields (and hooks when enabled) into targeted upload collections\n const collections = (config.collections || []).map((collection) => {\n if (!targetSlugs.includes(collection.slug)) {\n return collection\n }\n\n // Always inject fields for schema consistency (even when disabled)\n const fields = [...collection.fields, getImageOptimizerField(pluginOptions.fieldsOverride)]\n\n if (resolvedConfig.disabled) {\n return { ...collection, fields }\n }\n\n return {\n ...collection,\n fields,\n hooks: {\n ...collection.hooks,\n beforeChange: [\n ...(collection.hooks?.beforeChange || []),\n createBeforeChangeHook(resolvedConfig, collection.slug),\n ],\n afterChange: [\n ...(collection.hooks?.afterChange || []),\n createAfterChangeHook(resolvedConfig, collection.slug),\n ],\n },\n admin: {\n ...collection.admin,\n components: {\n ...collection.admin?.components,\n ...(resolvedConfig.clientOptimization && !collection.admin?.components?.edit?.Upload\n ? {\n edit: {\n ...collection.admin?.components?.edit,\n Upload: '@inoo-ch/payload-image-optimizer/client#UploadOptimizer',\n },\n }\n : {}),\n beforeListTable: [\n ...(collection.admin?.components?.beforeListTable || []),\n '@inoo-ch/payload-image-optimizer/client#RegenerationButton',\n ],\n },\n },\n }\n })\n\n const i18n = {\n ...config.i18n,\n translations: deepMergeSimple(translations, config.i18n?.translations ?? {}),\n }\n\n // If disabled, return with fields injected but no tasks/endpoints\n if (resolvedConfig.disabled) {\n return { ...config, collections, i18n }\n }\n\n return {\n ...config,\n collections,\n i18n,\n jobs: {\n ...config.jobs,\n tasks: [\n ...(config.jobs?.tasks || []),\n {\n slug: 'imageOptimizer_convertFormats',\n inputSchema: [\n { name: 'collectionSlug', type: 'text', required: true },\n { name: 'docId', type: 'text', required: true },\n ],\n outputSchema: [\n { name: 'variantsGenerated', type: 'number' },\n ],\n retries: 2,\n handler: createConvertFormatsHandler(resolvedConfig),\n } as any,\n {\n slug: 'imageOptimizer_regenerateDocument',\n inputSchema: [\n { name: 'collectionSlug', type: 'text', required: true },\n { name: 'docId', type: 'text', required: true },\n ],\n outputSchema: [\n { name: 'status', type: 'text' },\n { name: 'reason', type: 'text' },\n ],\n retries: 2,\n handler: createRegenerateDocumentHandler(resolvedConfig),\n } as any,\n ],\n },\n endpoints: [\n ...(config.endpoints ?? []),\n {\n path: '/image-optimizer/regenerate',\n method: 'post',\n handler: createRegenerateHandler(resolvedConfig),\n },\n {\n path: '/image-optimizer/regenerate',\n method: 'get',\n handler: createRegenerateStatusHandler(resolvedConfig),\n },\n ],\n }\n }\n"],"names":["deepMergeSimple","resolveConfig","translations","getImageOptimizerField","createBeforeChangeHook","createAfterChangeHook","createConvertFormatsHandler","createRegenerateDocumentHandler","createRegenerateHandler","createRegenerateStatusHandler","defaultImageOptimizerFields","encodeImageToThumbHash","decodeThumbHashToDataURL","maxDuration","imageOptimizer","pluginOptions","config","resolvedConfig","targetSlugs","Object","keys","collections","map","collection","includes","slug","fields","fieldsOverride","disabled","hooks","beforeChange","afterChange","admin","components","clientOptimization","edit","Upload","beforeListTable","i18n","jobs","tasks","inputSchema","name","type","required","outputSchema","retries","handler","endpoints","path","method"],"mappings":"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"}
package/dist/types.d.ts CHANGED
@@ -16,6 +16,7 @@ export type FieldsOverride = (args: {
16
16
  defaultFields: Field[];
17
17
  }) => Field[];
18
18
  export type ImageOptimizerConfig = {
19
+ clientOptimization?: boolean;
19
20
  collections: Partial<Record<CollectionSlug, true | CollectionOptimizerConfig>>;
20
21
  disabled?: boolean;
21
22
  fieldsOverride?: FieldsOverride;
@@ -37,6 +38,7 @@ export type ResolvedCollectionOptimizerConfig = {
37
38
  replaceOriginal: boolean;
38
39
  };
39
40
  export type ResolvedImageOptimizerConfig = Required<Pick<ImageOptimizerConfig, 'formats' | 'generateThumbHash' | 'maxDimensions' | 'stripMetadata'>> & {
41
+ clientOptimization: boolean;
40
42
  collections: ImageOptimizerConfig['collections'];
41
43
  disabled: boolean;
42
44
  replaceOriginal: boolean;
package/dist/types.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/types.ts"],"sourcesContent":["import type { CollectionSlug, Field } from 'payload'\n\nexport type ImageFormat = 'webp' | 'avif'\n\nexport type FormatQuality = {\n format: ImageFormat\n quality: number // 1-100\n}\n\nexport type CollectionOptimizerConfig = {\n formats?: FormatQuality[]\n maxDimensions?: { width: number; height: number }\n replaceOriginal?: boolean\n}\n\nexport type FieldsOverride = (args: { defaultFields: Field[] }) => Field[]\n\nexport type ImageOptimizerConfig = {\n collections: Partial<Record<CollectionSlug, true | CollectionOptimizerConfig>>\n disabled?: boolean\n fieldsOverride?: FieldsOverride\n formats?: FormatQuality[]\n generateThumbHash?: boolean\n maxDimensions?: { width: number; height: number }\n replaceOriginal?: boolean\n stripMetadata?: boolean\n}\n\nexport type ResolvedCollectionOptimizerConfig = {\n formats: FormatQuality[]\n maxDimensions: { width: number; height: number }\n replaceOriginal: boolean\n}\n\nexport type ResolvedImageOptimizerConfig = Required<\n Pick<ImageOptimizerConfig, 'formats' | 'generateThumbHash' | 'maxDimensions' | 'stripMetadata'>\n> & {\n collections: ImageOptimizerConfig['collections']\n disabled: boolean\n replaceOriginal: boolean\n}\n\nexport type ImageOptimizerData = {\n thumbHash?: string | null\n}\n\nexport type MediaResource = {\n url?: string | null\n alt?: string | null\n width?: number | null\n height?: number | null\n filename?: string | null\n focalX?: number | null\n focalY?: number | null\n imageOptimizer?: ImageOptimizerData | null\n updatedAt?: string\n}\n"],"names":[],"mappings":"AA8CA,WAUC"}
1
+ {"version":3,"sources":["../src/types.ts"],"sourcesContent":["import type { CollectionSlug, Field } from 'payload'\n\nexport type ImageFormat = 'webp' | 'avif'\n\nexport type FormatQuality = {\n format: ImageFormat\n quality: number // 1-100\n}\n\nexport type CollectionOptimizerConfig = {\n formats?: FormatQuality[]\n maxDimensions?: { width: number; height: number }\n replaceOriginal?: boolean\n}\n\nexport type FieldsOverride = (args: { defaultFields: Field[] }) => Field[]\n\nexport type ImageOptimizerConfig = {\n clientOptimization?: boolean\n collections: Partial<Record<CollectionSlug, true | CollectionOptimizerConfig>>\n disabled?: boolean\n fieldsOverride?: FieldsOverride\n formats?: FormatQuality[]\n generateThumbHash?: boolean\n maxDimensions?: { width: number; height: number }\n replaceOriginal?: boolean\n stripMetadata?: boolean\n}\n\nexport type ResolvedCollectionOptimizerConfig = {\n formats: FormatQuality[]\n maxDimensions: { width: number; height: number }\n replaceOriginal: boolean\n}\n\nexport type ResolvedImageOptimizerConfig = Required<\n Pick<ImageOptimizerConfig, 'formats' | 'generateThumbHash' | 'maxDimensions' | 'stripMetadata'>\n> & {\n clientOptimization: boolean\n collections: ImageOptimizerConfig['collections']\n disabled: boolean\n replaceOriginal: boolean\n}\n\nexport type ImageOptimizerData = {\n thumbHash?: string | null\n}\n\nexport type MediaResource = {\n url?: string | null\n alt?: string | null\n width?: number | null\n height?: number | null\n filename?: string | null\n focalX?: number | null\n focalY?: number | null\n imageOptimizer?: ImageOptimizerData | null\n updatedAt?: string\n}\n"],"names":[],"mappings":"AAgDA,WAUC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inoo-ch/payload-image-optimizer",
3
- "version": "1.4.7",
3
+ "version": "1.5.0",
4
4
  "description": "Payload CMS plugin for automatic image optimization — WebP/AVIF conversion, resize, EXIF strip, ThumbHash placeholders, and bulk regeneration",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -0,0 +1,94 @@
1
+ 'use client'
2
+
3
+ import React, { useCallback, useEffect, useRef } from 'react'
4
+ import { Upload, useDocumentInfo, useField } from '@payloadcms/ui'
5
+
6
+ const MAX_WIDTH = 2560
7
+ const MAX_HEIGHT = 2560
8
+ const JPEG_QUALITY = 0.85
9
+
10
+ const RESIZABLE_TYPES = new Set([
11
+ 'image/jpeg',
12
+ 'image/png',
13
+ 'image/webp',
14
+ 'image/bmp',
15
+ 'image/tiff',
16
+ ])
17
+
18
+ async function resizeImage(file: File): Promise<File> {
19
+ if (!RESIZABLE_TYPES.has(file.type)) return file
20
+
21
+ const bitmap = await createImageBitmap(file)
22
+ const { width, height } = bitmap
23
+
24
+ if (width <= MAX_WIDTH && height <= MAX_HEIGHT) {
25
+ bitmap.close()
26
+ return file
27
+ }
28
+
29
+ const ratio = Math.min(MAX_WIDTH / width, MAX_HEIGHT / height)
30
+ const targetWidth = Math.round(width * ratio)
31
+ const targetHeight = Math.round(height * ratio)
32
+
33
+ const canvas = document.createElement('canvas')
34
+ canvas.width = targetWidth
35
+ canvas.height = targetHeight
36
+ const ctx = canvas.getContext('2d')!
37
+ ctx.drawImage(bitmap, 0, 0, targetWidth, targetHeight)
38
+ bitmap.close()
39
+
40
+ // Keep PNG for transparency, convert everything else to JPEG
41
+ const outputType = file.type === 'image/png' ? 'image/png' : 'image/jpeg'
42
+ const quality = outputType === 'image/jpeg' ? JPEG_QUALITY : undefined
43
+ const ext = outputType === 'image/png' ? 'png' : 'jpg'
44
+
45
+ const blob = await new Promise<Blob>((resolve) => {
46
+ canvas.toBlob((b) => resolve(b!), outputType, quality)
47
+ })
48
+
49
+ const baseName = file.name.replace(/\.[^.]+$/, '')
50
+ return new File([blob], `${baseName}.${ext}`, {
51
+ type: outputType,
52
+ lastModified: Date.now(),
53
+ })
54
+ }
55
+
56
+ export const UploadOptimizer: React.FC = () => {
57
+ const { collectionSlug, docConfig, initialState } = useDocumentInfo()
58
+ const uploadConfig = docConfig && 'upload' in docConfig ? docConfig.upload : undefined
59
+ const { value: fileValue, setValue: setFileValue } = useField<File | null>({ path: 'file' })
60
+ const processedFiles = useRef(new WeakSet<File>())
61
+
62
+ const handleResize = useCallback(async (file: File): Promise<File> => {
63
+ return resizeImage(file)
64
+ }, [])
65
+
66
+ useEffect(() => {
67
+ if (!fileValue || !(fileValue instanceof File)) return
68
+ if (processedFiles.current.has(fileValue)) return
69
+
70
+ let cancelled = false
71
+
72
+ handleResize(fileValue).then((resized) => {
73
+ if (cancelled) return
74
+ processedFiles.current.add(resized)
75
+ if (resized !== fileValue) {
76
+ setFileValue(resized)
77
+ }
78
+ })
79
+
80
+ return () => {
81
+ cancelled = true
82
+ }
83
+ }, [fileValue, handleResize, setFileValue])
84
+
85
+ if (!collectionSlug || !uploadConfig) return null
86
+
87
+ return (
88
+ <Upload
89
+ collectionSlug={collectionSlug}
90
+ initialState={initialState}
91
+ uploadConfig={uploadConfig}
92
+ />
93
+ )
94
+ }
package/src/defaults.ts CHANGED
@@ -3,6 +3,7 @@ import type { CollectionSlug } from 'payload'
3
3
  import type { ImageOptimizerConfig, ResolvedCollectionOptimizerConfig, ResolvedImageOptimizerConfig } from './types.js'
4
4
 
5
5
  export const resolveConfig = (config: ImageOptimizerConfig): ResolvedImageOptimizerConfig => ({
6
+ clientOptimization: config.clientOptimization ?? false,
6
7
  collections: config.collections,
7
8
  disabled: config.disabled ?? false,
8
9
  formats: config.formats ?? [
@@ -6,3 +6,4 @@ 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
8
  export { RegenerationButton } from '../components/RegenerationButton.js'
9
+ export { UploadOptimizer } from '../components/UploadOptimizer.js'
package/src/index.ts CHANGED
@@ -16,6 +16,14 @@ export { defaultImageOptimizerFields } from './fields/imageOptimizerField.js'
16
16
 
17
17
  export { encodeImageToThumbHash, decodeThumbHashToDataURL } from './utilities/thumbhash.js'
18
18
 
19
+ /**
20
+ * Recommended maxDuration for the Payload API route on Vercel.
21
+ * Re-export this in your route file:
22
+ *
23
+ * export { maxDuration } from '@inoo-ch/payload-image-optimizer'
24
+ */
25
+ export const maxDuration = 60
26
+
19
27
  export const imageOptimizer =
20
28
  (pluginOptions: ImageOptimizerConfig) =>
21
29
  (config: Config): Config => {
@@ -53,6 +61,14 @@ export const imageOptimizer =
53
61
  ...collection.admin,
54
62
  components: {
55
63
  ...collection.admin?.components,
64
+ ...(resolvedConfig.clientOptimization && !collection.admin?.components?.edit?.Upload
65
+ ? {
66
+ edit: {
67
+ ...collection.admin?.components?.edit,
68
+ Upload: '@inoo-ch/payload-image-optimizer/client#UploadOptimizer',
69
+ },
70
+ }
71
+ : {}),
56
72
  beforeListTable: [
57
73
  ...(collection.admin?.components?.beforeListTable || []),
58
74
  '@inoo-ch/payload-image-optimizer/client#RegenerationButton',
package/src/types.ts CHANGED
@@ -16,6 +16,7 @@ export type CollectionOptimizerConfig = {
16
16
  export type FieldsOverride = (args: { defaultFields: Field[] }) => Field[]
17
17
 
18
18
  export type ImageOptimizerConfig = {
19
+ clientOptimization?: boolean
19
20
  collections: Partial<Record<CollectionSlug, true | CollectionOptimizerConfig>>
20
21
  disabled?: boolean
21
22
  fieldsOverride?: FieldsOverride
@@ -35,6 +36,7 @@ export type ResolvedCollectionOptimizerConfig = {
35
36
  export type ResolvedImageOptimizerConfig = Required<
36
37
  Pick<ImageOptimizerConfig, 'formats' | 'generateThumbHash' | 'maxDimensions' | 'stripMetadata'>
37
38
  > & {
39
+ clientOptimization: boolean
38
40
  collections: ImageOptimizerConfig['collections']
39
41
  disabled: boolean
40
42
  replaceOriginal: boolean