@inoo-ch/payload-image-optimizer 1.3.0 → 1.3.2

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.
@@ -2,29 +2,38 @@ import fs from 'fs/promises';
2
2
  import path from 'path';
3
3
  import { resolveCollectionConfig } from '../defaults.js';
4
4
  import { resolveStaticDir } from '../utilities/resolveStaticDir.js';
5
+ import { isCloudStorage } from '../utilities/storage.js';
5
6
  export const createAfterChangeHook = (resolvedConfig, collectionSlug)=>{
6
7
  return async ({ context, doc, req })=>{
7
8
  if (context?.imageOptimizer_skip) return doc;
8
- if (!req.file || !req.file.data || !req.file.mimetype?.startsWith('image/')) return doc;
9
+ // Use context flag from beforeChange instead of checking req.file.data directly.
10
+ // Cloud storage adapters may consume req.file.data in their own afterChange hook
11
+ // before ours runs, which would cause this guard to bail out and leave status as 'pending'.
12
+ if (!context?.imageOptimizer_hasUpload) return doc;
9
13
  const collectionConfig = req.payload.collections[collectionSlug].config;
10
- const staticDir = resolveStaticDir(collectionConfig);
11
- const perCollectionConfig = resolveCollectionConfig(resolvedConfig, collectionSlug);
12
- // Overwrite the file on disk with the processed (stripped/resized/converted) buffer
13
- // Payload 3.0 writes the original buffer to disk; we replace it here
14
- const processedBuffer = context.imageOptimizer_processedBuffer;
15
- if (processedBuffer && doc.filename && staticDir) {
16
- const safeFilename = path.basename(doc.filename);
17
- const filePath = path.join(staticDir, safeFilename);
18
- await fs.writeFile(filePath, processedBuffer);
19
- // If replaceOriginal changed the filename, clean up the old file Payload wrote
20
- const originalFilename = context.imageOptimizer_originalFilename;
21
- if (originalFilename && originalFilename !== safeFilename) {
22
- const oldFilePath = path.join(staticDir, path.basename(originalFilename));
23
- await fs.unlink(oldFilePath).catch(()=>{
24
- // Old file may not exist if Payload used the new filename
25
- });
14
+ const cloudStorage = isCloudStorage(collectionConfig);
15
+ // When using local storage, overwrite the file on disk with the processed buffer.
16
+ // Payload's uploadFiles step writes the original buffer; we replace it here.
17
+ // When using cloud storage, skip the cloud adapter's afterChange hook already
18
+ // uploads the correct buffer from req.file.data (set in our beforeChange hook).
19
+ if (!cloudStorage) {
20
+ const staticDir = resolveStaticDir(collectionConfig);
21
+ const processedBuffer = context.imageOptimizer_processedBuffer;
22
+ if (processedBuffer && doc.filename && staticDir) {
23
+ const safeFilename = path.basename(doc.filename);
24
+ const filePath = path.join(staticDir, safeFilename);
25
+ await fs.writeFile(filePath, processedBuffer);
26
+ // If replaceOriginal changed the filename, clean up the old file Payload wrote
27
+ const originalFilename = context.imageOptimizer_originalFilename;
28
+ if (originalFilename && originalFilename !== safeFilename) {
29
+ const oldFilePath = path.join(staticDir, path.basename(originalFilename));
30
+ await fs.unlink(oldFilePath).catch(()=>{
31
+ // Old file may not exist if Payload used the new filename
32
+ });
33
+ }
26
34
  }
27
35
  }
36
+ const perCollectionConfig = resolveCollectionConfig(resolvedConfig, collectionSlug);
28
37
  // When replaceOriginal is on and only one format is configured, the main file
29
38
  // is already converted — skip the async job and mark complete immediately.
30
39
  if (perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length <= 1) {
@@ -33,8 +42,31 @@ export const createAfterChangeHook = (resolvedConfig, collectionSlug)=>{
33
42
  id: doc.id,
34
43
  data: {
35
44
  imageOptimizer: {
45
+ ...doc.imageOptimizer,
46
+ status: 'complete',
47
+ variants: [],
48
+ error: null
49
+ }
50
+ },
51
+ context: {
52
+ imageOptimizer_skip: true
53
+ }
54
+ });
55
+ return doc;
56
+ }
57
+ // With cloud storage, variant files cannot be written — skip the async job
58
+ // and mark complete. CDN-level image optimization (e.g. Next.js Image) can
59
+ // serve alternative formats on the fly.
60
+ if (cloudStorage) {
61
+ await req.payload.update({
62
+ collection: collectionSlug,
63
+ id: doc.id,
64
+ data: {
65
+ imageOptimizer: {
66
+ ...doc.imageOptimizer,
36
67
  status: 'complete',
37
- variants: []
68
+ variants: [],
69
+ error: null
38
70
  }
39
71
  },
40
72
  context: {
@@ -43,7 +75,7 @@ export const createAfterChangeHook = (resolvedConfig, collectionSlug)=>{
43
75
  });
44
76
  return doc;
45
77
  }
46
- // Queue async format conversion job for remaining variants
78
+ // Queue async format conversion job for remaining variants (local storage only)
47
79
  await req.payload.jobs.queue({
48
80
  task: 'imageOptimizer_convertFormats',
49
81
  input: {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/hooks/afterChange.ts"],"sourcesContent":["import fs from 'fs/promises'\nimport path from 'path'\nimport type { CollectionAfterChangeHook } from 'payload'\n\nimport type { ResolvedImageOptimizerConfig } from '../types.js'\nimport { resolveCollectionConfig } from '../defaults.js'\nimport { resolveStaticDir } from '../utilities/resolveStaticDir.js'\n\nexport const createAfterChangeHook = (\n resolvedConfig: ResolvedImageOptimizerConfig,\n collectionSlug: string,\n): CollectionAfterChangeHook => {\n return async ({ context, doc, req }) => {\n if (context?.imageOptimizer_skip) return doc\n\n if (!req.file || !req.file.data || !req.file.mimetype?.startsWith('image/')) return doc\n\n const collectionConfig = req.payload.collections[collectionSlug as keyof typeof req.payload.collections].config\n const staticDir = resolveStaticDir(collectionConfig)\n\n const perCollectionConfig = resolveCollectionConfig(resolvedConfig, collectionSlug)\n\n // Overwrite the file on disk with the processed (stripped/resized/converted) buffer\n // Payload 3.0 writes the original buffer to disk; we replace it here\n const processedBuffer = context.imageOptimizer_processedBuffer as Buffer | undefined\n if (processedBuffer && doc.filename && staticDir) {\n const safeFilename = path.basename(doc.filename as string)\n const filePath = path.join(staticDir, safeFilename)\n await fs.writeFile(filePath, processedBuffer)\n\n // If replaceOriginal changed the filename, clean up the old file Payload wrote\n const originalFilename = context.imageOptimizer_originalFilename as string | undefined\n if (originalFilename && originalFilename !== safeFilename) {\n const oldFilePath = path.join(staticDir, path.basename(originalFilename))\n await fs.unlink(oldFilePath).catch(() => {\n // Old file may not exist if Payload used the new filename\n })\n }\n }\n\n // When replaceOriginal is on and only one format is configured, the main file\n // is already converted — skip the async job and mark complete immediately.\n if (perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length <= 1) {\n await req.payload.update({\n collection: collectionSlug,\n id: doc.id,\n data: {\n imageOptimizer: {\n status: 'complete',\n variants: [],\n },\n },\n context: { imageOptimizer_skip: true },\n })\n return doc\n }\n\n // Queue async format conversion job for remaining variants\n await req.payload.jobs.queue({\n task: 'imageOptimizer_convertFormats',\n input: {\n collectionSlug,\n docId: String(doc.id),\n },\n })\n\n req.payload.jobs.run().catch((err: unknown) => {\n req.payload.logger.error({ err }, 'Image optimizer job runner failed')\n })\n\n return doc\n }\n}\n"],"names":["fs","path","resolveCollectionConfig","resolveStaticDir","createAfterChangeHook","resolvedConfig","collectionSlug","context","doc","req","imageOptimizer_skip","file","data","mimetype","startsWith","collectionConfig","payload","collections","config","staticDir","perCollectionConfig","processedBuffer","imageOptimizer_processedBuffer","filename","safeFilename","basename","filePath","join","writeFile","originalFilename","imageOptimizer_originalFilename","oldFilePath","unlink","catch","replaceOriginal","formats","length","update","collection","id","imageOptimizer","status","variants","jobs","queue","task","input","docId","String","run","err","logger","error"],"mappings":"AAAA,OAAOA,QAAQ,cAAa;AAC5B,OAAOC,UAAU,OAAM;AAIvB,SAASC,uBAAuB,QAAQ,iBAAgB;AACxD,SAASC,gBAAgB,QAAQ,mCAAkC;AAEnE,OAAO,MAAMC,wBAAwB,CACnCC,gBACAC;IAEA,OAAO,OAAO,EAAEC,OAAO,EAAEC,GAAG,EAAEC,GAAG,EAAE;QACjC,IAAIF,SAASG,qBAAqB,OAAOF;QAEzC,IAAI,CAACC,IAAIE,IAAI,IAAI,CAACF,IAAIE,IAAI,CAACC,IAAI,IAAI,CAACH,IAAIE,IAAI,CAACE,QAAQ,EAAEC,WAAW,WAAW,OAAON;QAEpF,MAAMO,mBAAmBN,IAAIO,OAAO,CAACC,WAAW,CAACX,eAAuD,CAACY,MAAM;QAC/G,MAAMC,YAAYhB,iBAAiBY;QAEnC,MAAMK,sBAAsBlB,wBAAwBG,gBAAgBC;QAEpE,oFAAoF;QACpF,qEAAqE;QACrE,MAAMe,kBAAkBd,QAAQe,8BAA8B;QAC9D,IAAID,mBAAmBb,IAAIe,QAAQ,IAAIJ,WAAW;YAChD,MAAMK,eAAevB,KAAKwB,QAAQ,CAACjB,IAAIe,QAAQ;YAC/C,MAAMG,WAAWzB,KAAK0B,IAAI,CAACR,WAAWK;YACtC,MAAMxB,GAAG4B,SAAS,CAACF,UAAUL;YAE7B,+EAA+E;YAC/E,MAAMQ,mBAAmBtB,QAAQuB,+BAA+B;YAChE,IAAID,oBAAoBA,qBAAqBL,cAAc;gBACzD,MAAMO,cAAc9B,KAAK0B,IAAI,CAACR,WAAWlB,KAAKwB,QAAQ,CAACI;gBACvD,MAAM7B,GAAGgC,MAAM,CAACD,aAAaE,KAAK,CAAC;gBACjC,0DAA0D;gBAC5D;YACF;QACF;QAEA,8EAA8E;QAC9E,2EAA2E;QAC3E,IAAIb,oBAAoBc,eAAe,IAAId,oBAAoBe,OAAO,CAACC,MAAM,IAAI,GAAG;YAClF,MAAM3B,IAAIO,OAAO,CAACqB,MAAM,CAAC;gBACvBC,YAAYhC;gBACZiC,IAAI/B,IAAI+B,EAAE;gBACV3B,MAAM;oBACJ4B,gBAAgB;wBACdC,QAAQ;wBACRC,UAAU,EAAE;oBACd;gBACF;gBACAnC,SAAS;oBAAEG,qBAAqB;gBAAK;YACvC;YACA,OAAOF;QACT;QAEA,2DAA2D;QAC3D,MAAMC,IAAIO,OAAO,CAAC2B,IAAI,CAACC,KAAK,CAAC;YAC3BC,MAAM;YACNC,OAAO;gBACLxC;gBACAyC,OAAOC,OAAOxC,IAAI+B,EAAE;YACtB;QACF;QAEA9B,IAAIO,OAAO,CAAC2B,IAAI,CAACM,GAAG,GAAGhB,KAAK,CAAC,CAACiB;YAC5BzC,IAAIO,OAAO,CAACmC,MAAM,CAACC,KAAK,CAAC;gBAAEF;YAAI,GAAG;QACpC;QAEA,OAAO1C;IACT;AACF,EAAC"}
1
+ {"version":3,"sources":["../../src/hooks/afterChange.ts"],"sourcesContent":["import fs from 'fs/promises'\nimport path from 'path'\nimport type { CollectionAfterChangeHook } from 'payload'\n\nimport type { ResolvedImageOptimizerConfig } from '../types.js'\nimport { resolveCollectionConfig } from '../defaults.js'\nimport { resolveStaticDir } from '../utilities/resolveStaticDir.js'\nimport { isCloudStorage } from '../utilities/storage.js'\n\nexport const createAfterChangeHook = (\n resolvedConfig: ResolvedImageOptimizerConfig,\n collectionSlug: string,\n): CollectionAfterChangeHook => {\n return async ({ context, doc, req }) => {\n if (context?.imageOptimizer_skip) return doc\n\n // Use context flag from beforeChange instead of checking req.file.data directly.\n // Cloud storage adapters may consume req.file.data in their own afterChange hook\n // before ours runs, which would cause this guard to bail out and leave status as 'pending'.\n if (!context?.imageOptimizer_hasUpload) return doc\n\n const collectionConfig = req.payload.collections[collectionSlug as keyof typeof req.payload.collections].config\n const cloudStorage = isCloudStorage(collectionConfig)\n\n // When using local storage, overwrite the file on disk with the processed buffer.\n // Payload's uploadFiles step writes the original buffer; we replace it here.\n // When using cloud storage, skip — the cloud adapter's afterChange hook already\n // uploads the correct buffer from req.file.data (set in our beforeChange hook).\n if (!cloudStorage) {\n const staticDir = resolveStaticDir(collectionConfig)\n const processedBuffer = context.imageOptimizer_processedBuffer as Buffer | undefined\n if (processedBuffer && doc.filename && staticDir) {\n const safeFilename = path.basename(doc.filename as string)\n const filePath = path.join(staticDir, safeFilename)\n await fs.writeFile(filePath, processedBuffer)\n\n // If replaceOriginal changed the filename, clean up the old file Payload wrote\n const originalFilename = context.imageOptimizer_originalFilename as string | undefined\n if (originalFilename && originalFilename !== safeFilename) {\n const oldFilePath = path.join(staticDir, path.basename(originalFilename))\n await fs.unlink(oldFilePath).catch(() => {\n // Old file may not exist if Payload used the new filename\n })\n }\n }\n }\n\n const perCollectionConfig = resolveCollectionConfig(resolvedConfig, collectionSlug)\n\n // When replaceOriginal is on and only one format is configured, the main file\n // is already converted — skip the async job and mark complete immediately.\n if (perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length <= 1) {\n await req.payload.update({\n collection: collectionSlug,\n id: doc.id,\n data: {\n imageOptimizer: {\n ...doc.imageOptimizer,\n status: 'complete',\n variants: [],\n error: null,\n },\n },\n context: { imageOptimizer_skip: true },\n })\n return doc\n }\n\n // With cloud storage, variant files cannot be written — skip the async job\n // and mark complete. CDN-level image optimization (e.g. Next.js Image) can\n // serve alternative formats on the fly.\n if (cloudStorage) {\n await req.payload.update({\n collection: collectionSlug,\n id: doc.id,\n data: {\n imageOptimizer: {\n ...doc.imageOptimizer,\n status: 'complete',\n variants: [],\n error: null,\n },\n },\n context: { imageOptimizer_skip: true },\n })\n return doc\n }\n\n // Queue async format conversion job for remaining variants (local storage only)\n await req.payload.jobs.queue({\n task: 'imageOptimizer_convertFormats',\n input: {\n collectionSlug,\n docId: String(doc.id),\n },\n })\n\n req.payload.jobs.run().catch((err: unknown) => {\n req.payload.logger.error({ err }, 'Image optimizer job runner failed')\n })\n\n return doc\n }\n}\n"],"names":["fs","path","resolveCollectionConfig","resolveStaticDir","isCloudStorage","createAfterChangeHook","resolvedConfig","collectionSlug","context","doc","req","imageOptimizer_skip","imageOptimizer_hasUpload","collectionConfig","payload","collections","config","cloudStorage","staticDir","processedBuffer","imageOptimizer_processedBuffer","filename","safeFilename","basename","filePath","join","writeFile","originalFilename","imageOptimizer_originalFilename","oldFilePath","unlink","catch","perCollectionConfig","replaceOriginal","formats","length","update","collection","id","data","imageOptimizer","status","variants","error","jobs","queue","task","input","docId","String","run","err","logger"],"mappings":"AAAA,OAAOA,QAAQ,cAAa;AAC5B,OAAOC,UAAU,OAAM;AAIvB,SAASC,uBAAuB,QAAQ,iBAAgB;AACxD,SAASC,gBAAgB,QAAQ,mCAAkC;AACnE,SAASC,cAAc,QAAQ,0BAAyB;AAExD,OAAO,MAAMC,wBAAwB,CACnCC,gBACAC;IAEA,OAAO,OAAO,EAAEC,OAAO,EAAEC,GAAG,EAAEC,GAAG,EAAE;QACjC,IAAIF,SAASG,qBAAqB,OAAOF;QAEzC,iFAAiF;QACjF,iFAAiF;QACjF,4FAA4F;QAC5F,IAAI,CAACD,SAASI,0BAA0B,OAAOH;QAE/C,MAAMI,mBAAmBH,IAAII,OAAO,CAACC,WAAW,CAACR,eAAuD,CAACS,MAAM;QAC/G,MAAMC,eAAeb,eAAeS;QAEpC,kFAAkF;QAClF,6EAA6E;QAC7E,gFAAgF;QAChF,gFAAgF;QAChF,IAAI,CAACI,cAAc;YACjB,MAAMC,YAAYf,iBAAiBU;YACnC,MAAMM,kBAAkBX,QAAQY,8BAA8B;YAC9D,IAAID,mBAAmBV,IAAIY,QAAQ,IAAIH,WAAW;gBAChD,MAAMI,eAAerB,KAAKsB,QAAQ,CAACd,IAAIY,QAAQ;gBAC/C,MAAMG,WAAWvB,KAAKwB,IAAI,CAACP,WAAWI;gBACtC,MAAMtB,GAAG0B,SAAS,CAACF,UAAUL;gBAE7B,+EAA+E;gBAC/E,MAAMQ,mBAAmBnB,QAAQoB,+BAA+B;gBAChE,IAAID,oBAAoBA,qBAAqBL,cAAc;oBACzD,MAAMO,cAAc5B,KAAKwB,IAAI,CAACP,WAAWjB,KAAKsB,QAAQ,CAACI;oBACvD,MAAM3B,GAAG8B,MAAM,CAACD,aAAaE,KAAK,CAAC;oBACjC,0DAA0D;oBAC5D;gBACF;YACF;QACF;QAEA,MAAMC,sBAAsB9B,wBAAwBI,gBAAgBC;QAEpE,8EAA8E;QAC9E,2EAA2E;QAC3E,IAAIyB,oBAAoBC,eAAe,IAAID,oBAAoBE,OAAO,CAACC,MAAM,IAAI,GAAG;YAClF,MAAMzB,IAAII,OAAO,CAACsB,MAAM,CAAC;gBACvBC,YAAY9B;gBACZ+B,IAAI7B,IAAI6B,EAAE;gBACVC,MAAM;oBACJC,gBAAgB;wBACd,GAAG/B,IAAI+B,cAAc;wBACrBC,QAAQ;wBACRC,UAAU,EAAE;wBACZC,OAAO;oBACT;gBACF;gBACAnC,SAAS;oBAAEG,qBAAqB;gBAAK;YACvC;YACA,OAAOF;QACT;QAEA,2EAA2E;QAC3E,2EAA2E;QAC3E,wCAAwC;QACxC,IAAIQ,cAAc;YAChB,MAAMP,IAAII,OAAO,CAACsB,MAAM,CAAC;gBACvBC,YAAY9B;gBACZ+B,IAAI7B,IAAI6B,EAAE;gBACVC,MAAM;oBACJC,gBAAgB;wBACd,GAAG/B,IAAI+B,cAAc;wBACrBC,QAAQ;wBACRC,UAAU,EAAE;wBACZC,OAAO;oBACT;gBACF;gBACAnC,SAAS;oBAAEG,qBAAqB;gBAAK;YACvC;YACA,OAAOF;QACT;QAEA,gFAAgF;QAChF,MAAMC,IAAII,OAAO,CAAC8B,IAAI,CAACC,KAAK,CAAC;YAC3BC,MAAM;YACNC,OAAO;gBACLxC;gBACAyC,OAAOC,OAAOxC,IAAI6B,EAAE;YACtB;QACF;QAEA5B,IAAII,OAAO,CAAC8B,IAAI,CAACM,GAAG,GAAGnB,KAAK,CAAC,CAACoB;YAC5BzC,IAAII,OAAO,CAACsC,MAAM,CAACT,KAAK,CAAC;gBAAEQ;YAAI,GAAG;QACpC;QAEA,OAAO1C;IACT;AACF,EAAC"}
@@ -33,9 +33,19 @@ export const createBeforeChangeHook = (resolvedConfig, collectionSlug)=>{
33
33
  if (resolvedConfig.generateThumbHash) {
34
34
  data.imageOptimizer.thumbHash = await generateThumbHash(finalBuffer);
35
35
  }
36
- // Store processed buffer in context for afterChange to write to disk
37
- // (Payload 3.0 does not use modified req.file.data for the disk write)
36
+ // Write processed buffer back to req.file so cloud storage adapters
37
+ // (which read req.file in their afterChange hook) upload the optimized version.
38
+ // Payload's own uploadFiles step does NOT re-read req.file.data for its local
39
+ // disk write, so we also store the buffer in context for our afterChange hook
40
+ // to overwrite the local file when local storage is enabled.
41
+ req.file.data = finalBuffer;
42
+ req.file.size = finalSize;
43
+ if (perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0) {
44
+ req.file.name = data.filename;
45
+ req.file.mimetype = data.mimeType;
46
+ }
38
47
  context.imageOptimizer_processedBuffer = finalBuffer;
48
+ context.imageOptimizer_hasUpload = true;
39
49
  return data;
40
50
  };
41
51
  };
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/hooks/beforeChange.ts"],"sourcesContent":["import path from 'path'\nimport type { CollectionBeforeChangeHook } from 'payload'\n\nimport type { ResolvedImageOptimizerConfig } from '../types.js'\nimport { resolveCollectionConfig } from '../defaults.js'\nimport { convertFormat, generateThumbHash, stripAndResize } from '../processing/index.js'\n\nexport const createBeforeChangeHook = (\n resolvedConfig: ResolvedImageOptimizerConfig,\n collectionSlug: string,\n): CollectionBeforeChangeHook => {\n return async ({ context, data, req }) => {\n if (context?.imageOptimizer_skip) return data\n\n if (!req.file || !req.file.data || !req.file.mimetype?.startsWith('image/')) return data\n\n const originalSize = req.file.data.length\n\n const perCollectionConfig = resolveCollectionConfig(resolvedConfig, collectionSlug)\n\n // Process in memory: strip EXIF, resize, generate blur\n const processed = await stripAndResize(\n req.file.data,\n perCollectionConfig.maxDimensions,\n resolvedConfig.stripMetadata,\n )\n\n let finalBuffer = processed.buffer\n let finalSize = processed.size\n\n if (perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0) {\n // Convert to primary format (first in the formats array)\n const primaryFormat = perCollectionConfig.formats[0]\n const converted = await convertFormat(processed.buffer, primaryFormat.format, primaryFormat.quality)\n\n finalBuffer = converted.buffer\n finalSize = converted.size\n\n // Update filename and mimeType so Payload stores the correct metadata\n const originalFilename = data.filename || req.file.name || ''\n const newFilename = `${path.parse(originalFilename).name}.${primaryFormat.format}`\n context.imageOptimizer_originalFilename = originalFilename\n data.filename = newFilename\n data.mimeType = converted.mimeType\n data.filesize = finalSize\n }\n\n data.imageOptimizer = {\n originalSize,\n optimizedSize: finalSize,\n status: 'pending',\n }\n\n if (resolvedConfig.generateThumbHash) {\n data.imageOptimizer.thumbHash = await generateThumbHash(finalBuffer)\n }\n\n // Store processed buffer in context for afterChange to write to disk\n // (Payload 3.0 does not use modified req.file.data for the disk write)\n context.imageOptimizer_processedBuffer = finalBuffer\n\n return data\n }\n}\n"],"names":["path","resolveCollectionConfig","convertFormat","generateThumbHash","stripAndResize","createBeforeChangeHook","resolvedConfig","collectionSlug","context","data","req","imageOptimizer_skip","file","mimetype","startsWith","originalSize","length","perCollectionConfig","processed","maxDimensions","stripMetadata","finalBuffer","buffer","finalSize","size","replaceOriginal","formats","primaryFormat","converted","format","quality","originalFilename","filename","name","newFilename","parse","imageOptimizer_originalFilename","mimeType","filesize","imageOptimizer","optimizedSize","status","thumbHash","imageOptimizer_processedBuffer"],"mappings":"AAAA,OAAOA,UAAU,OAAM;AAIvB,SAASC,uBAAuB,QAAQ,iBAAgB;AACxD,SAASC,aAAa,EAAEC,iBAAiB,EAAEC,cAAc,QAAQ,yBAAwB;AAEzF,OAAO,MAAMC,yBAAyB,CACpCC,gBACAC;IAEA,OAAO,OAAO,EAAEC,OAAO,EAAEC,IAAI,EAAEC,GAAG,EAAE;QAClC,IAAIF,SAASG,qBAAqB,OAAOF;QAEzC,IAAI,CAACC,IAAIE,IAAI,IAAI,CAACF,IAAIE,IAAI,CAACH,IAAI,IAAI,CAACC,IAAIE,IAAI,CAACC,QAAQ,EAAEC,WAAW,WAAW,OAAOL;QAEpF,MAAMM,eAAeL,IAAIE,IAAI,CAACH,IAAI,CAACO,MAAM;QAEzC,MAAMC,sBAAsBhB,wBAAwBK,gBAAgBC;QAEpE,uDAAuD;QACvD,MAAMW,YAAY,MAAMd,eACtBM,IAAIE,IAAI,CAACH,IAAI,EACbQ,oBAAoBE,aAAa,EACjCb,eAAec,aAAa;QAG9B,IAAIC,cAAcH,UAAUI,MAAM;QAClC,IAAIC,YAAYL,UAAUM,IAAI;QAE9B,IAAIP,oBAAoBQ,eAAe,IAAIR,oBAAoBS,OAAO,CAACV,MAAM,GAAG,GAAG;YACjF,yDAAyD;YACzD,MAAMW,gBAAgBV,oBAAoBS,OAAO,CAAC,EAAE;YACpD,MAAME,YAAY,MAAM1B,cAAcgB,UAAUI,MAAM,EAAEK,cAAcE,MAAM,EAAEF,cAAcG,OAAO;YAEnGT,cAAcO,UAAUN,MAAM;YAC9BC,YAAYK,UAAUJ,IAAI;YAE1B,sEAAsE;YACtE,MAAMO,mBAAmBtB,KAAKuB,QAAQ,IAAItB,IAAIE,IAAI,CAACqB,IAAI,IAAI;YAC3D,MAAMC,cAAc,GAAGlC,KAAKmC,KAAK,CAACJ,kBAAkBE,IAAI,CAAC,CAAC,EAAEN,cAAcE,MAAM,EAAE;YAClFrB,QAAQ4B,+BAA+B,GAAGL;YAC1CtB,KAAKuB,QAAQ,GAAGE;YAChBzB,KAAK4B,QAAQ,GAAGT,UAAUS,QAAQ;YAClC5B,KAAK6B,QAAQ,GAAGf;QAClB;QAEAd,KAAK8B,cAAc,GAAG;YACpBxB;YACAyB,eAAejB;YACfkB,QAAQ;QACV;QAEA,IAAInC,eAAeH,iBAAiB,EAAE;YACpCM,KAAK8B,cAAc,CAACG,SAAS,GAAG,MAAMvC,kBAAkBkB;QAC1D;QAEA,qEAAqE;QACrE,uEAAuE;QACvEb,QAAQmC,8BAA8B,GAAGtB;QAEzC,OAAOZ;IACT;AACF,EAAC"}
1
+ {"version":3,"sources":["../../src/hooks/beforeChange.ts"],"sourcesContent":["import path from 'path'\nimport type { CollectionBeforeChangeHook } from 'payload'\n\nimport type { ResolvedImageOptimizerConfig } from '../types.js'\nimport { resolveCollectionConfig } from '../defaults.js'\nimport { convertFormat, generateThumbHash, stripAndResize } from '../processing/index.js'\n\nexport const createBeforeChangeHook = (\n resolvedConfig: ResolvedImageOptimizerConfig,\n collectionSlug: string,\n): CollectionBeforeChangeHook => {\n return async ({ context, data, req }) => {\n if (context?.imageOptimizer_skip) return data\n\n if (!req.file || !req.file.data || !req.file.mimetype?.startsWith('image/')) return data\n\n const originalSize = req.file.data.length\n\n const perCollectionConfig = resolveCollectionConfig(resolvedConfig, collectionSlug)\n\n // Process in memory: strip EXIF, resize, generate blur\n const processed = await stripAndResize(\n req.file.data,\n perCollectionConfig.maxDimensions,\n resolvedConfig.stripMetadata,\n )\n\n let finalBuffer = processed.buffer\n let finalSize = processed.size\n\n if (perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0) {\n // Convert to primary format (first in the formats array)\n const primaryFormat = perCollectionConfig.formats[0]\n const converted = await convertFormat(processed.buffer, primaryFormat.format, primaryFormat.quality)\n\n finalBuffer = converted.buffer\n finalSize = converted.size\n\n // Update filename and mimeType so Payload stores the correct metadata\n const originalFilename = data.filename || req.file.name || ''\n const newFilename = `${path.parse(originalFilename).name}.${primaryFormat.format}`\n context.imageOptimizer_originalFilename = originalFilename\n data.filename = newFilename\n data.mimeType = converted.mimeType\n data.filesize = finalSize\n }\n\n data.imageOptimizer = {\n originalSize,\n optimizedSize: finalSize,\n status: 'pending',\n }\n\n if (resolvedConfig.generateThumbHash) {\n data.imageOptimizer.thumbHash = await generateThumbHash(finalBuffer)\n }\n\n // Write processed buffer back to req.file so cloud storage adapters\n // (which read req.file in their afterChange hook) upload the optimized version.\n // Payload's own uploadFiles step does NOT re-read req.file.data for its local\n // disk write, so we also store the buffer in context for our afterChange hook\n // to overwrite the local file when local storage is enabled.\n req.file.data = finalBuffer\n req.file.size = finalSize\n if (perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0) {\n req.file.name = data.filename\n req.file.mimetype = data.mimeType\n }\n context.imageOptimizer_processedBuffer = finalBuffer\n context.imageOptimizer_hasUpload = true\n\n return data\n }\n}\n"],"names":["path","resolveCollectionConfig","convertFormat","generateThumbHash","stripAndResize","createBeforeChangeHook","resolvedConfig","collectionSlug","context","data","req","imageOptimizer_skip","file","mimetype","startsWith","originalSize","length","perCollectionConfig","processed","maxDimensions","stripMetadata","finalBuffer","buffer","finalSize","size","replaceOriginal","formats","primaryFormat","converted","format","quality","originalFilename","filename","name","newFilename","parse","imageOptimizer_originalFilename","mimeType","filesize","imageOptimizer","optimizedSize","status","thumbHash","imageOptimizer_processedBuffer","imageOptimizer_hasUpload"],"mappings":"AAAA,OAAOA,UAAU,OAAM;AAIvB,SAASC,uBAAuB,QAAQ,iBAAgB;AACxD,SAASC,aAAa,EAAEC,iBAAiB,EAAEC,cAAc,QAAQ,yBAAwB;AAEzF,OAAO,MAAMC,yBAAyB,CACpCC,gBACAC;IAEA,OAAO,OAAO,EAAEC,OAAO,EAAEC,IAAI,EAAEC,GAAG,EAAE;QAClC,IAAIF,SAASG,qBAAqB,OAAOF;QAEzC,IAAI,CAACC,IAAIE,IAAI,IAAI,CAACF,IAAIE,IAAI,CAACH,IAAI,IAAI,CAACC,IAAIE,IAAI,CAACC,QAAQ,EAAEC,WAAW,WAAW,OAAOL;QAEpF,MAAMM,eAAeL,IAAIE,IAAI,CAACH,IAAI,CAACO,MAAM;QAEzC,MAAMC,sBAAsBhB,wBAAwBK,gBAAgBC;QAEpE,uDAAuD;QACvD,MAAMW,YAAY,MAAMd,eACtBM,IAAIE,IAAI,CAACH,IAAI,EACbQ,oBAAoBE,aAAa,EACjCb,eAAec,aAAa;QAG9B,IAAIC,cAAcH,UAAUI,MAAM;QAClC,IAAIC,YAAYL,UAAUM,IAAI;QAE9B,IAAIP,oBAAoBQ,eAAe,IAAIR,oBAAoBS,OAAO,CAACV,MAAM,GAAG,GAAG;YACjF,yDAAyD;YACzD,MAAMW,gBAAgBV,oBAAoBS,OAAO,CAAC,EAAE;YACpD,MAAME,YAAY,MAAM1B,cAAcgB,UAAUI,MAAM,EAAEK,cAAcE,MAAM,EAAEF,cAAcG,OAAO;YAEnGT,cAAcO,UAAUN,MAAM;YAC9BC,YAAYK,UAAUJ,IAAI;YAE1B,sEAAsE;YACtE,MAAMO,mBAAmBtB,KAAKuB,QAAQ,IAAItB,IAAIE,IAAI,CAACqB,IAAI,IAAI;YAC3D,MAAMC,cAAc,GAAGlC,KAAKmC,KAAK,CAACJ,kBAAkBE,IAAI,CAAC,CAAC,EAAEN,cAAcE,MAAM,EAAE;YAClFrB,QAAQ4B,+BAA+B,GAAGL;YAC1CtB,KAAKuB,QAAQ,GAAGE;YAChBzB,KAAK4B,QAAQ,GAAGT,UAAUS,QAAQ;YAClC5B,KAAK6B,QAAQ,GAAGf;QAClB;QAEAd,KAAK8B,cAAc,GAAG;YACpBxB;YACAyB,eAAejB;YACfkB,QAAQ;QACV;QAEA,IAAInC,eAAeH,iBAAiB,EAAE;YACpCM,KAAK8B,cAAc,CAACG,SAAS,GAAG,MAAMvC,kBAAkBkB;QAC1D;QAEA,oEAAoE;QACpE,gFAAgF;QAChF,8EAA8E;QAC9E,8EAA8E;QAC9E,6DAA6D;QAC7DX,IAAIE,IAAI,CAACH,IAAI,GAAGY;QAChBX,IAAIE,IAAI,CAACY,IAAI,GAAGD;QAChB,IAAIN,oBAAoBQ,eAAe,IAAIR,oBAAoBS,OAAO,CAACV,MAAM,GAAG,GAAG;YACjFN,IAAIE,IAAI,CAACqB,IAAI,GAAGxB,KAAKuB,QAAQ;YAC7BtB,IAAIE,IAAI,CAACC,QAAQ,GAAGJ,KAAK4B,QAAQ;QACnC;QACA7B,QAAQmC,8BAA8B,GAAGtB;QACzCb,QAAQoC,wBAAwB,GAAG;QAEnC,OAAOnC;IACT;AACF,EAAC"}
@@ -3,6 +3,7 @@ import path from 'path';
3
3
  import { resolveCollectionConfig } from '../defaults.js';
4
4
  import { convertFormat } from '../processing/index.js';
5
5
  import { resolveStaticDir } from '../utilities/resolveStaticDir.js';
6
+ import { fetchFileBuffer, isCloudStorage } from '../utilities/storage.js';
6
7
  export const createConvertFormatsHandler = (resolvedConfig)=>{
7
8
  return async ({ input, req })=>{
8
9
  try {
@@ -11,19 +12,42 @@ export const createConvertFormatsHandler = (resolvedConfig)=>{
11
12
  id: input.docId
12
13
  });
13
14
  const collectionConfig = req.payload.collections[input.collectionSlug].config;
15
+ const cloudStorage = isCloudStorage(collectionConfig);
16
+ // Cloud storage: variant files cannot be uploaded without direct adapter access.
17
+ // Mark as complete — CDN-level image optimization handles format conversion.
18
+ if (cloudStorage) {
19
+ await req.payload.update({
20
+ collection: input.collectionSlug,
21
+ id: input.docId,
22
+ data: {
23
+ imageOptimizer: {
24
+ ...doc.imageOptimizer,
25
+ status: 'complete',
26
+ variants: [],
27
+ error: null
28
+ }
29
+ },
30
+ context: {
31
+ imageOptimizer_skip: true
32
+ }
33
+ });
34
+ return {
35
+ output: {
36
+ variantsGenerated: 0
37
+ }
38
+ };
39
+ }
14
40
  const staticDir = resolveStaticDir(collectionConfig);
15
41
  if (!staticDir) {
16
42
  throw new Error(`No staticDir configured for collection "${input.collectionSlug}"`);
17
43
  }
18
- // Sanitize filename to prevent path traversal
19
- const safeFilename = path.basename(doc.filename);
20
- const filePath = path.join(staticDir, safeFilename);
21
- const fileBuffer = await fs.readFile(filePath);
44
+ const fileBuffer = await fetchFileBuffer(doc, collectionConfig);
22
45
  const variants = [];
23
46
  const perCollectionConfig = resolveCollectionConfig(resolvedConfig, input.collectionSlug);
24
47
  // When replaceOriginal is on, the main file is already in the primary format —
25
48
  // skip it and only generate variants for the remaining formats.
26
49
  const formatsToGenerate = perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0 ? perCollectionConfig.formats.slice(1) : perCollectionConfig.formats;
50
+ const safeFilename = path.basename(doc.filename);
27
51
  for (const format of formatsToGenerate){
28
52
  const result = await convertFormat(fileBuffer, format.format, format.quality);
29
53
  const variantFilename = `${path.parse(safeFilename).name}-optimized.${format.format}`;
@@ -43,8 +67,10 @@ export const createConvertFormatsHandler = (resolvedConfig)=>{
43
67
  id: input.docId,
44
68
  data: {
45
69
  imageOptimizer: {
70
+ ...doc.imageOptimizer,
46
71
  status: 'complete',
47
- variants
72
+ variants,
73
+ error: null
48
74
  }
49
75
  },
50
76
  context: {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/tasks/convertFormats.ts"],"sourcesContent":["import fs from 'fs/promises'\nimport path from 'path'\n\nimport type { CollectionSlug } from 'payload'\n\nimport type { ResolvedImageOptimizerConfig } from '../types.js'\nimport { resolveCollectionConfig } from '../defaults.js'\nimport { convertFormat } from '../processing/index.js'\nimport { resolveStaticDir } from '../utilities/resolveStaticDir.js'\n\nexport const createConvertFormatsHandler = (resolvedConfig: ResolvedImageOptimizerConfig) => {\n return async ({ input, req }: { input: { collectionSlug: string; docId: string }; req: any }) => {\n try {\n const doc = await req.payload.findByID({\n collection: input.collectionSlug as CollectionSlug,\n id: input.docId,\n })\n\n const collectionConfig = req.payload.collections[input.collectionSlug as keyof typeof req.payload.collections].config\n const staticDir = resolveStaticDir(collectionConfig)\n\n if (!staticDir) {\n throw new Error(`No staticDir configured for collection \"${input.collectionSlug}\"`)\n }\n\n // Sanitize filename to prevent path traversal\n const safeFilename = path.basename(doc.filename)\n const filePath = path.join(staticDir, safeFilename)\n const fileBuffer = await fs.readFile(filePath)\n\n const variants: Array<{\n filename: string\n filesize: number\n format: string\n height: number\n mimeType: string\n url: string\n width: number\n }> = []\n\n const perCollectionConfig = resolveCollectionConfig(resolvedConfig, input.collectionSlug)\n\n // When replaceOriginal is on, the main file is already in the primary format —\n // skip it and only generate variants for the remaining formats.\n const formatsToGenerate = perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0\n ? perCollectionConfig.formats.slice(1)\n : perCollectionConfig.formats\n\n for (const format of formatsToGenerate) {\n const result = await convertFormat(fileBuffer, format.format, format.quality)\n const variantFilename = `${path.parse(safeFilename).name}-optimized.${format.format}`\n\n await fs.writeFile(path.join(staticDir, variantFilename), result.buffer)\n\n variants.push({\n format: format.format,\n filename: variantFilename,\n filesize: result.size,\n width: result.width,\n height: result.height,\n mimeType: result.mimeType,\n url: `/api/${input.collectionSlug}/file/${variantFilename}`,\n })\n }\n\n await req.payload.update({\n collection: input.collectionSlug as CollectionSlug,\n id: input.docId,\n data: {\n imageOptimizer: {\n status: 'complete',\n variants,\n },\n },\n context: { imageOptimizer_skip: true },\n })\n\n return { output: { variantsGenerated: variants.length } }\n } catch (err) {\n const errorMessage = err instanceof Error ? err.message : String(err)\n\n try {\n await req.payload.update({\n collection: input.collectionSlug as CollectionSlug,\n id: input.docId,\n data: {\n imageOptimizer: {\n status: 'error',\n error: errorMessage,\n },\n },\n context: { imageOptimizer_skip: true },\n })\n } catch (updateErr) {\n req.payload.logger.error(\n { err: updateErr },\n 'Failed to persist error status for image optimizer',\n )\n }\n\n throw err\n }\n }\n}\n"],"names":["fs","path","resolveCollectionConfig","convertFormat","resolveStaticDir","createConvertFormatsHandler","resolvedConfig","input","req","doc","payload","findByID","collection","collectionSlug","id","docId","collectionConfig","collections","config","staticDir","Error","safeFilename","basename","filename","filePath","join","fileBuffer","readFile","variants","perCollectionConfig","formatsToGenerate","replaceOriginal","formats","length","slice","format","result","quality","variantFilename","parse","name","writeFile","buffer","push","filesize","size","width","height","mimeType","url","update","data","imageOptimizer","status","context","imageOptimizer_skip","output","variantsGenerated","err","errorMessage","message","String","error","updateErr","logger"],"mappings":"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"}
1
+ {"version":3,"sources":["../../src/tasks/convertFormats.ts"],"sourcesContent":["import fs from 'fs/promises'\nimport path from 'path'\n\nimport type { CollectionSlug } from 'payload'\n\nimport type { ResolvedImageOptimizerConfig } from '../types.js'\nimport { resolveCollectionConfig } from '../defaults.js'\nimport { convertFormat } from '../processing/index.js'\nimport { resolveStaticDir } from '../utilities/resolveStaticDir.js'\nimport { fetchFileBuffer, isCloudStorage } from '../utilities/storage.js'\n\nexport const createConvertFormatsHandler = (resolvedConfig: ResolvedImageOptimizerConfig) => {\n return async ({ input, req }: { input: { collectionSlug: string; docId: string }; req: any }) => {\n try {\n const doc = await req.payload.findByID({\n collection: input.collectionSlug as CollectionSlug,\n id: input.docId,\n })\n\n const collectionConfig = req.payload.collections[input.collectionSlug as keyof typeof req.payload.collections].config\n const cloudStorage = isCloudStorage(collectionConfig)\n\n // Cloud storage: variant files cannot be uploaded without direct adapter access.\n // Mark as complete — CDN-level image optimization handles format conversion.\n if (cloudStorage) {\n await req.payload.update({\n collection: input.collectionSlug as CollectionSlug,\n id: input.docId,\n data: {\n imageOptimizer: {\n ...doc.imageOptimizer,\n status: 'complete',\n variants: [],\n error: null,\n },\n },\n context: { imageOptimizer_skip: true },\n })\n return { output: { variantsGenerated: 0 } }\n }\n\n const staticDir = resolveStaticDir(collectionConfig)\n if (!staticDir) {\n throw new Error(`No staticDir configured for collection \"${input.collectionSlug}\"`)\n }\n\n const fileBuffer = await fetchFileBuffer(doc, collectionConfig)\n\n const variants: Array<{\n filename: string\n filesize: number\n format: string\n height: number\n mimeType: string\n url: string\n width: number\n }> = []\n\n const perCollectionConfig = resolveCollectionConfig(resolvedConfig, input.collectionSlug)\n\n // When replaceOriginal is on, the main file is already in the primary format —\n // skip it and only generate variants for the remaining formats.\n const formatsToGenerate = perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0\n ? perCollectionConfig.formats.slice(1)\n : perCollectionConfig.formats\n\n const safeFilename = path.basename(doc.filename)\n\n for (const format of formatsToGenerate) {\n const result = await convertFormat(fileBuffer, format.format, format.quality)\n const variantFilename = `${path.parse(safeFilename).name}-optimized.${format.format}`\n\n await fs.writeFile(path.join(staticDir, variantFilename), result.buffer)\n\n variants.push({\n format: format.format,\n filename: variantFilename,\n filesize: result.size,\n width: result.width,\n height: result.height,\n mimeType: result.mimeType,\n url: `/api/${input.collectionSlug}/file/${variantFilename}`,\n })\n }\n\n await req.payload.update({\n collection: input.collectionSlug as CollectionSlug,\n id: input.docId,\n data: {\n imageOptimizer: {\n ...doc.imageOptimizer,\n status: 'complete',\n variants,\n error: null,\n },\n },\n context: { imageOptimizer_skip: true },\n })\n\n return { output: { variantsGenerated: variants.length } }\n } catch (err) {\n const errorMessage = err instanceof Error ? err.message : String(err)\n\n try {\n await req.payload.update({\n collection: input.collectionSlug as CollectionSlug,\n id: input.docId,\n data: {\n imageOptimizer: {\n status: 'error',\n error: errorMessage,\n },\n },\n context: { imageOptimizer_skip: true },\n })\n } catch (updateErr) {\n req.payload.logger.error(\n { err: updateErr },\n 'Failed to persist error status for image optimizer',\n )\n }\n\n throw err\n }\n }\n}\n"],"names":["fs","path","resolveCollectionConfig","convertFormat","resolveStaticDir","fetchFileBuffer","isCloudStorage","createConvertFormatsHandler","resolvedConfig","input","req","doc","payload","findByID","collection","collectionSlug","id","docId","collectionConfig","collections","config","cloudStorage","update","data","imageOptimizer","status","variants","error","context","imageOptimizer_skip","output","variantsGenerated","staticDir","Error","fileBuffer","perCollectionConfig","formatsToGenerate","replaceOriginal","formats","length","slice","safeFilename","basename","filename","format","result","quality","variantFilename","parse","name","writeFile","join","buffer","push","filesize","size","width","height","mimeType","url","err","errorMessage","message","String","updateErr","logger"],"mappings":"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"}
@@ -3,6 +3,7 @@ import path from 'path';
3
3
  import { resolveCollectionConfig } from '../defaults.js';
4
4
  import { stripAndResize, generateThumbHash, convertFormat } from '../processing/index.js';
5
5
  import { resolveStaticDir } from '../utilities/resolveStaticDir.js';
6
+ import { fetchFileBuffer, isCloudStorage } from '../utilities/storage.js';
6
7
  export const createRegenerateDocumentHandler = (resolvedConfig)=>{
7
8
  return async ({ input, req })=>{
8
9
  try {
@@ -20,28 +21,12 @@ export const createRegenerateDocumentHandler = (resolvedConfig)=>{
20
21
  };
21
22
  }
22
23
  const collectionConfig = req.payload.collections[input.collectionSlug].config;
23
- const staticDir = resolveStaticDir(collectionConfig);
24
- if (!staticDir) {
25
- throw new Error(`No staticDir configured for collection "${input.collectionSlug}"`);
26
- }
27
- // Sanitize filename to prevent path traversal
28
- const safeFilename = path.basename(doc.filename);
29
- const filePath = path.join(staticDir, safeFilename);
30
- let fileBuffer;
31
- try {
32
- fileBuffer = await fs.readFile(filePath);
33
- } catch {
34
- // If file not on disk, try fetching from URL
35
- if (doc.url) {
36
- const url = doc.url.startsWith('http') ? doc.url : `${process.env.NEXT_PUBLIC_SERVER_URL || ''}${doc.url}`;
37
- const response = await fetch(url);
38
- fileBuffer = Buffer.from(await response.arrayBuffer());
39
- } else {
40
- throw new Error(`File not found: ${filePath}`);
41
- }
42
- }
24
+ const cloudStorage = isCloudStorage(collectionConfig);
25
+ const fileBuffer = await fetchFileBuffer(doc, collectionConfig);
43
26
  const originalSize = fileBuffer.length;
44
27
  const perCollectionConfig = resolveCollectionConfig(resolvedConfig, input.collectionSlug);
28
+ // Sanitize filename to prevent path traversal
29
+ const safeFilename = path.basename(doc.filename);
45
30
  // Step 1: Strip metadata + resize
46
31
  const processed = await stripAndResize(fileBuffer, perCollectionConfig.maxDimensions, resolvedConfig.stripMetadata);
47
32
  let mainBuffer = processed.buffer;
@@ -57,60 +42,96 @@ export const createRegenerateDocumentHandler = (resolvedConfig)=>{
57
42
  newFilename = `${path.parse(safeFilename).name}.${primaryFormat.format}`;
58
43
  newMimeType = converted.mimeType;
59
44
  }
60
- // Write optimized file to disk
61
- const newFilePath = path.join(staticDir, newFilename);
62
- await fs.writeFile(newFilePath, mainBuffer);
63
- // Clean up old file if filename changed
64
- if (newFilename !== safeFilename) {
65
- await fs.unlink(filePath).catch(()=>{});
66
- }
67
45
  // Step 2: Generate ThumbHash
68
46
  let thumbHash;
69
47
  if (resolvedConfig.generateThumbHash) {
70
48
  thumbHash = await generateThumbHash(mainBuffer);
71
49
  }
72
- // Step 3: Convert to configured formats (skip primary when replaceOriginal)
73
- const formatsToGenerate = perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0 ? perCollectionConfig.formats.slice(1) : perCollectionConfig.formats;
50
+ // Step 3: Store the optimized file
74
51
  const variants = [];
75
- for (const format of formatsToGenerate){
76
- const result = await convertFormat(mainBuffer, format.format, format.quality);
77
- const variantFilename = `${path.parse(newFilename).name}-optimized.${format.format}`;
78
- await fs.writeFile(path.join(staticDir, variantFilename), result.buffer);
79
- variants.push({
80
- format: format.format,
81
- filename: variantFilename,
82
- filesize: result.size,
83
- width: result.width,
84
- height: result.height,
85
- mimeType: result.mimeType,
86
- url: `/api/${input.collectionSlug}/file/${variantFilename}`
52
+ if (cloudStorage) {
53
+ // Cloud storage: re-upload the optimized file via Payload's update API.
54
+ // This triggers the cloud adapter's afterChange hook which uploads to cloud.
55
+ const updateData = {
56
+ imageOptimizer: {
57
+ originalSize,
58
+ optimizedSize: mainSize,
59
+ status: 'complete',
60
+ thumbHash,
61
+ variants: [],
62
+ error: null
63
+ }
64
+ };
65
+ if (newFilename !== safeFilename) {
66
+ updateData.filename = newFilename;
67
+ updateData.filesize = mainSize;
68
+ updateData.mimeType = newMimeType;
69
+ }
70
+ await req.payload.update({
71
+ collection: input.collectionSlug,
72
+ id: input.docId,
73
+ data: updateData,
74
+ file: {
75
+ data: mainBuffer,
76
+ mimetype: newMimeType || doc.mimeType,
77
+ name: newFilename,
78
+ size: mainSize
79
+ },
80
+ context: {
81
+ imageOptimizer_skip: true
82
+ }
87
83
  });
88
- }
89
- // Step 4: Update the document with all optimization data
90
- const updateData = {
91
- imageOptimizer: {
92
- originalSize,
93
- optimizedSize: mainSize,
94
- status: 'complete',
95
- thumbHash,
96
- variants,
97
- error: null
84
+ } else {
85
+ // Local storage: write files to disk
86
+ const staticDir = resolveStaticDir(collectionConfig);
87
+ const newFilePath = path.join(staticDir, newFilename);
88
+ await fs.writeFile(newFilePath, mainBuffer);
89
+ // Clean up old file if filename changed
90
+ if (newFilename !== safeFilename) {
91
+ const oldFilePath = path.join(staticDir, safeFilename);
92
+ await fs.unlink(oldFilePath).catch(()=>{});
98
93
  }
99
- };
100
- // Update filename, mimeType, and filesize when replaceOriginal changed them
101
- if (newFilename !== safeFilename) {
102
- updateData.filename = newFilename;
103
- updateData.filesize = mainSize;
104
- updateData.mimeType = newMimeType;
105
- }
106
- await req.payload.update({
107
- collection: input.collectionSlug,
108
- id: input.docId,
109
- data: updateData,
110
- context: {
111
- imageOptimizer_skip: true
94
+ // Generate variant files (local storage only)
95
+ const formatsToGenerate = perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0 ? perCollectionConfig.formats.slice(1) : perCollectionConfig.formats;
96
+ for (const format of formatsToGenerate){
97
+ const result = await convertFormat(mainBuffer, format.format, format.quality);
98
+ const variantFilename = `${path.parse(newFilename).name}-optimized.${format.format}`;
99
+ await fs.writeFile(path.join(staticDir, variantFilename), result.buffer);
100
+ variants.push({
101
+ format: format.format,
102
+ filename: variantFilename,
103
+ filesize: result.size,
104
+ width: result.width,
105
+ height: result.height,
106
+ mimeType: result.mimeType,
107
+ url: `/api/${input.collectionSlug}/file/${variantFilename}`
108
+ });
112
109
  }
113
- });
110
+ // Update the document with optimization data
111
+ const updateData = {
112
+ imageOptimizer: {
113
+ originalSize,
114
+ optimizedSize: mainSize,
115
+ status: 'complete',
116
+ thumbHash,
117
+ variants,
118
+ error: null
119
+ }
120
+ };
121
+ if (newFilename !== safeFilename) {
122
+ updateData.filename = newFilename;
123
+ updateData.filesize = mainSize;
124
+ updateData.mimeType = newMimeType;
125
+ }
126
+ await req.payload.update({
127
+ collection: input.collectionSlug,
128
+ id: input.docId,
129
+ data: updateData,
130
+ context: {
131
+ imageOptimizer_skip: true
132
+ }
133
+ });
134
+ }
114
135
  return {
115
136
  output: {
116
137
  status: 'complete'
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/tasks/regenerateDocument.ts"],"sourcesContent":["import fs from 'fs/promises'\nimport path from 'path'\n\nimport type { CollectionSlug } from 'payload'\n\nimport type { ResolvedImageOptimizerConfig } from '../types.js'\nimport { resolveCollectionConfig } from '../defaults.js'\nimport { stripAndResize, generateThumbHash, convertFormat } from '../processing/index.js'\nimport { resolveStaticDir } from '../utilities/resolveStaticDir.js'\n\nexport const createRegenerateDocumentHandler = (resolvedConfig: ResolvedImageOptimizerConfig) => {\n return async ({ input, req }: { input: { collectionSlug: string; docId: string }; req: any }) => {\n try {\n const doc = await req.payload.findByID({\n collection: input.collectionSlug as CollectionSlug,\n id: input.docId,\n })\n\n // Skip non-image documents\n if (!doc.mimeType || !doc.mimeType.startsWith('image/')) {\n return { output: { status: 'skipped', reason: 'not-image' } }\n }\n\n const collectionConfig = req.payload.collections[input.collectionSlug as keyof typeof req.payload.collections].config\n const staticDir = resolveStaticDir(collectionConfig)\n\n if (!staticDir) {\n throw new Error(`No staticDir configured for collection \"${input.collectionSlug}\"`)\n }\n\n // Sanitize filename to prevent path traversal\n const safeFilename = path.basename(doc.filename)\n const filePath = path.join(staticDir, safeFilename)\n\n let fileBuffer: Buffer\n try {\n fileBuffer = await fs.readFile(filePath)\n } catch {\n // If file not on disk, try fetching from URL\n if (doc.url) {\n const url = doc.url.startsWith('http')\n ? doc.url\n : `${process.env.NEXT_PUBLIC_SERVER_URL || ''}${doc.url}`\n const response = await fetch(url)\n fileBuffer = Buffer.from(await response.arrayBuffer())\n } else {\n throw new Error(`File not found: ${filePath}`)\n }\n }\n\n const originalSize = fileBuffer.length\n const perCollectionConfig = resolveCollectionConfig(resolvedConfig, input.collectionSlug)\n\n // Step 1: Strip metadata + resize\n const processed = await stripAndResize(\n fileBuffer,\n perCollectionConfig.maxDimensions,\n resolvedConfig.stripMetadata,\n )\n\n let mainBuffer = processed.buffer\n let mainSize = processed.size\n let newFilename = safeFilename\n let newMimeType: string | undefined\n\n // Step 1b: If replaceOriginal, convert main file to primary format\n if (perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0) {\n const primaryFormat = perCollectionConfig.formats[0]\n const converted = await convertFormat(processed.buffer, primaryFormat.format, primaryFormat.quality)\n mainBuffer = converted.buffer\n mainSize = converted.size\n newFilename = `${path.parse(safeFilename).name}.${primaryFormat.format}`\n newMimeType = converted.mimeType\n }\n\n // Write optimized file to disk\n const newFilePath = path.join(staticDir, newFilename)\n await fs.writeFile(newFilePath, mainBuffer)\n\n // Clean up old file if filename changed\n if (newFilename !== safeFilename) {\n await fs.unlink(filePath).catch(() => {})\n }\n\n // Step 2: Generate ThumbHash\n let thumbHash: string | undefined\n if (resolvedConfig.generateThumbHash) {\n thumbHash = await generateThumbHash(mainBuffer)\n }\n\n // Step 3: Convert to configured formats (skip primary when replaceOriginal)\n const formatsToGenerate = perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0\n ? perCollectionConfig.formats.slice(1)\n : perCollectionConfig.formats\n\n const variants: Array<{\n filename: string\n filesize: number\n format: string\n height: number\n mimeType: string\n url: string\n width: number\n }> = []\n\n for (const format of formatsToGenerate) {\n const result = await convertFormat(mainBuffer, format.format, format.quality)\n const variantFilename = `${path.parse(newFilename).name}-optimized.${format.format}`\n await fs.writeFile(path.join(staticDir, variantFilename), result.buffer)\n\n variants.push({\n format: format.format,\n filename: variantFilename,\n filesize: result.size,\n width: result.width,\n height: result.height,\n mimeType: result.mimeType,\n url: `/api/${input.collectionSlug}/file/${variantFilename}`,\n })\n }\n\n // Step 4: Update the document with all optimization data\n const updateData: Record<string, any> = {\n imageOptimizer: {\n originalSize,\n optimizedSize: mainSize,\n status: 'complete',\n thumbHash,\n variants,\n error: null,\n },\n }\n\n // Update filename, mimeType, and filesize when replaceOriginal changed them\n if (newFilename !== safeFilename) {\n updateData.filename = newFilename\n updateData.filesize = mainSize\n updateData.mimeType = newMimeType\n }\n\n await req.payload.update({\n collection: input.collectionSlug as CollectionSlug,\n id: input.docId,\n data: updateData,\n context: { imageOptimizer_skip: true },\n })\n\n return { output: { status: 'complete' } }\n } catch (err) {\n const errorMessage = err instanceof Error ? err.message : String(err)\n\n try {\n await req.payload.update({\n collection: input.collectionSlug as CollectionSlug,\n id: input.docId,\n data: {\n imageOptimizer: {\n status: 'error',\n error: errorMessage,\n },\n },\n context: { imageOptimizer_skip: true },\n })\n } catch (updateErr) {\n req.payload.logger.error(\n { err: updateErr },\n 'Failed to persist error status for image optimizer regeneration',\n )\n }\n\n throw err\n }\n }\n}\n"],"names":["fs","path","resolveCollectionConfig","stripAndResize","generateThumbHash","convertFormat","resolveStaticDir","createRegenerateDocumentHandler","resolvedConfig","input","req","doc","payload","findByID","collection","collectionSlug","id","docId","mimeType","startsWith","output","status","reason","collectionConfig","collections","config","staticDir","Error","safeFilename","basename","filename","filePath","join","fileBuffer","readFile","url","process","env","NEXT_PUBLIC_SERVER_URL","response","fetch","Buffer","from","arrayBuffer","originalSize","length","perCollectionConfig","processed","maxDimensions","stripMetadata","mainBuffer","buffer","mainSize","size","newFilename","newMimeType","replaceOriginal","formats","primaryFormat","converted","format","quality","parse","name","newFilePath","writeFile","unlink","catch","thumbHash","formatsToGenerate","slice","variants","result","variantFilename","push","filesize","width","height","updateData","imageOptimizer","optimizedSize","error","update","data","context","imageOptimizer_skip","err","errorMessage","message","String","updateErr","logger"],"mappings":"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"}
1
+ {"version":3,"sources":["../../src/tasks/regenerateDocument.ts"],"sourcesContent":["import fs from 'fs/promises'\nimport path from 'path'\n\nimport type { CollectionSlug } from 'payload'\n\nimport type { ResolvedImageOptimizerConfig } from '../types.js'\nimport { resolveCollectionConfig } from '../defaults.js'\nimport { stripAndResize, generateThumbHash, convertFormat } from '../processing/index.js'\nimport { resolveStaticDir } from '../utilities/resolveStaticDir.js'\nimport { fetchFileBuffer, isCloudStorage } from '../utilities/storage.js'\n\nexport const createRegenerateDocumentHandler = (resolvedConfig: ResolvedImageOptimizerConfig) => {\n return async ({ input, req }: { input: { collectionSlug: string; docId: string }; req: any }) => {\n try {\n const doc = await req.payload.findByID({\n collection: input.collectionSlug as CollectionSlug,\n id: input.docId,\n })\n\n // Skip non-image documents\n if (!doc.mimeType || !doc.mimeType.startsWith('image/')) {\n return { output: { status: 'skipped', reason: 'not-image' } }\n }\n\n const collectionConfig = req.payload.collections[input.collectionSlug as keyof typeof req.payload.collections].config\n const cloudStorage = isCloudStorage(collectionConfig)\n\n const fileBuffer = await fetchFileBuffer(doc, collectionConfig)\n const originalSize = fileBuffer.length\n const perCollectionConfig = resolveCollectionConfig(resolvedConfig, input.collectionSlug)\n\n // Sanitize filename to prevent path traversal\n const safeFilename = path.basename(doc.filename)\n\n // Step 1: Strip metadata + resize\n const processed = await stripAndResize(\n fileBuffer,\n perCollectionConfig.maxDimensions,\n resolvedConfig.stripMetadata,\n )\n\n let mainBuffer = processed.buffer\n let mainSize = processed.size\n let newFilename = safeFilename\n let newMimeType: string | undefined\n\n // Step 1b: If replaceOriginal, convert main file to primary format\n if (perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0) {\n const primaryFormat = perCollectionConfig.formats[0]\n const converted = await convertFormat(processed.buffer, primaryFormat.format, primaryFormat.quality)\n mainBuffer = converted.buffer\n mainSize = converted.size\n newFilename = `${path.parse(safeFilename).name}.${primaryFormat.format}`\n newMimeType = converted.mimeType\n }\n\n // Step 2: Generate ThumbHash\n let thumbHash: string | undefined\n if (resolvedConfig.generateThumbHash) {\n thumbHash = await generateThumbHash(mainBuffer)\n }\n\n // Step 3: Store the optimized file\n const variants: Array<{\n filename: string\n filesize: number\n format: string\n height: number\n mimeType: string\n url: string\n width: number\n }> = []\n\n if (cloudStorage) {\n // Cloud storage: re-upload the optimized file via Payload's update API.\n // This triggers the cloud adapter's afterChange hook which uploads to cloud.\n const updateData: Record<string, any> = {\n imageOptimizer: {\n originalSize,\n optimizedSize: mainSize,\n status: 'complete',\n thumbHash,\n variants: [],\n error: null,\n },\n }\n\n if (newFilename !== safeFilename) {\n updateData.filename = newFilename\n updateData.filesize = mainSize\n updateData.mimeType = newMimeType\n }\n\n await req.payload.update({\n collection: input.collectionSlug as CollectionSlug,\n id: input.docId,\n data: updateData,\n file: {\n data: mainBuffer,\n mimetype: newMimeType || doc.mimeType,\n name: newFilename,\n size: mainSize,\n },\n context: { imageOptimizer_skip: true },\n })\n } else {\n // Local storage: write files to disk\n const staticDir = resolveStaticDir(collectionConfig)\n const newFilePath = path.join(staticDir, newFilename)\n await fs.writeFile(newFilePath, mainBuffer)\n\n // Clean up old file if filename changed\n if (newFilename !== safeFilename) {\n const oldFilePath = path.join(staticDir, safeFilename)\n await fs.unlink(oldFilePath).catch(() => {})\n }\n\n // Generate variant files (local storage only)\n const formatsToGenerate = perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length > 0\n ? perCollectionConfig.formats.slice(1)\n : perCollectionConfig.formats\n\n for (const format of formatsToGenerate) {\n const result = await convertFormat(mainBuffer, format.format, format.quality)\n const variantFilename = `${path.parse(newFilename).name}-optimized.${format.format}`\n await fs.writeFile(path.join(staticDir, variantFilename), result.buffer)\n\n variants.push({\n format: format.format,\n filename: variantFilename,\n filesize: result.size,\n width: result.width,\n height: result.height,\n mimeType: result.mimeType,\n url: `/api/${input.collectionSlug}/file/${variantFilename}`,\n })\n }\n\n // Update the document with optimization data\n const updateData: Record<string, any> = {\n imageOptimizer: {\n originalSize,\n optimizedSize: mainSize,\n status: 'complete',\n thumbHash,\n variants,\n error: null,\n },\n }\n\n if (newFilename !== safeFilename) {\n updateData.filename = newFilename\n updateData.filesize = mainSize\n updateData.mimeType = newMimeType\n }\n\n await req.payload.update({\n collection: input.collectionSlug as CollectionSlug,\n id: input.docId,\n data: updateData,\n context: { imageOptimizer_skip: true },\n })\n }\n\n return { output: { status: 'complete' } }\n } catch (err) {\n const errorMessage = err instanceof Error ? err.message : String(err)\n\n try {\n await req.payload.update({\n collection: input.collectionSlug as CollectionSlug,\n id: input.docId,\n data: {\n imageOptimizer: {\n status: 'error',\n error: errorMessage,\n },\n },\n context: { imageOptimizer_skip: true },\n })\n } catch (updateErr) {\n req.payload.logger.error(\n { err: updateErr },\n 'Failed to persist error status for image optimizer regeneration',\n )\n }\n\n throw err\n }\n }\n}\n"],"names":["fs","path","resolveCollectionConfig","stripAndResize","generateThumbHash","convertFormat","resolveStaticDir","fetchFileBuffer","isCloudStorage","createRegenerateDocumentHandler","resolvedConfig","input","req","doc","payload","findByID","collection","collectionSlug","id","docId","mimeType","startsWith","output","status","reason","collectionConfig","collections","config","cloudStorage","fileBuffer","originalSize","length","perCollectionConfig","safeFilename","basename","filename","processed","maxDimensions","stripMetadata","mainBuffer","buffer","mainSize","size","newFilename","newMimeType","replaceOriginal","formats","primaryFormat","converted","format","quality","parse","name","thumbHash","variants","updateData","imageOptimizer","optimizedSize","error","filesize","update","data","file","mimetype","context","imageOptimizer_skip","staticDir","newFilePath","join","writeFile","oldFilePath","unlink","catch","formatsToGenerate","slice","result","variantFilename","push","width","height","url","err","errorMessage","Error","message","String","updateErr","logger"],"mappings":"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"}
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Returns true when the collection uses cloud/external storage (disableLocalStorage: true).
3
+ * When true, files are uploaded by external adapter hooks — no local FS writes should happen.
4
+ */
5
+ export declare function isCloudStorage(collectionConfig: {
6
+ upload?: boolean | Record<string, any>;
7
+ }): boolean;
8
+ /**
9
+ * Reads a file buffer from local disk or fetches it from URL.
10
+ * Tries local disk first (when available), falls back to URL fetch.
11
+ * This makes the plugin storage-agnostic — works with local FS and cloud storage alike.
12
+ */
13
+ export declare function fetchFileBuffer(doc: {
14
+ filename?: string;
15
+ url?: string;
16
+ }, collectionConfig: {
17
+ upload?: boolean | Record<string, any>;
18
+ }): Promise<Buffer>;
@@ -0,0 +1,39 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import { resolveStaticDir } from './resolveStaticDir.js';
4
+ /**
5
+ * Returns true when the collection uses cloud/external storage (disableLocalStorage: true).
6
+ * When true, files are uploaded by external adapter hooks — no local FS writes should happen.
7
+ */ export function isCloudStorage(collectionConfig) {
8
+ return typeof collectionConfig.upload === 'object' && collectionConfig.upload.disableLocalStorage === true;
9
+ }
10
+ /**
11
+ * Reads a file buffer from local disk or fetches it from URL.
12
+ * Tries local disk first (when available), falls back to URL fetch.
13
+ * This makes the plugin storage-agnostic — works with local FS and cloud storage alike.
14
+ */ export async function fetchFileBuffer(doc, collectionConfig) {
15
+ const safeFilename = doc.filename ? path.basename(doc.filename) : undefined;
16
+ // Try local disk first (only when local storage is enabled)
17
+ if (!isCloudStorage(collectionConfig) && safeFilename) {
18
+ const staticDir = resolveStaticDir(collectionConfig);
19
+ if (staticDir) {
20
+ try {
21
+ return await fs.readFile(path.join(staticDir, safeFilename));
22
+ } catch {
23
+ // Fall through to URL fetch
24
+ }
25
+ }
26
+ }
27
+ // Fetch from URL (works for cloud storage and as fallback for local)
28
+ if (doc.url) {
29
+ const url = doc.url.startsWith('http') ? doc.url : `${process.env.NEXT_PUBLIC_SERVER_URL || ''}${doc.url}`;
30
+ const response = await fetch(url);
31
+ if (!response.ok) {
32
+ throw new Error(`Failed to fetch file from ${url}: ${response.status} ${response.statusText}`);
33
+ }
34
+ return Buffer.from(await response.arrayBuffer());
35
+ }
36
+ throw new Error(`Cannot read file: no local path or URL available for "${doc.filename}"`);
37
+ }
38
+
39
+ //# sourceMappingURL=storage.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/utilities/storage.ts"],"sourcesContent":["import fs from 'fs/promises'\nimport path from 'path'\n\nimport { resolveStaticDir } from './resolveStaticDir.js'\n\n/**\n * Returns true when the collection uses cloud/external storage (disableLocalStorage: true).\n * When true, files are uploaded by external adapter hooks — no local FS writes should happen.\n */\nexport function isCloudStorage(collectionConfig: { upload?: boolean | Record<string, any> }): boolean {\n return typeof collectionConfig.upload === 'object' && collectionConfig.upload.disableLocalStorage === true\n}\n\n/**\n * Reads a file buffer from local disk or fetches it from URL.\n * Tries local disk first (when available), falls back to URL fetch.\n * This makes the plugin storage-agnostic — works with local FS and cloud storage alike.\n */\nexport async function fetchFileBuffer(\n doc: { filename?: string; url?: string },\n collectionConfig: { upload?: boolean | Record<string, any> },\n): Promise<Buffer> {\n const safeFilename = doc.filename ? path.basename(doc.filename) : undefined\n\n // Try local disk first (only when local storage is enabled)\n if (!isCloudStorage(collectionConfig) && safeFilename) {\n const staticDir = resolveStaticDir(collectionConfig)\n if (staticDir) {\n try {\n return await fs.readFile(path.join(staticDir, safeFilename))\n } catch {\n // Fall through to URL fetch\n }\n }\n }\n\n // Fetch from URL (works for cloud storage and as fallback for local)\n if (doc.url) {\n const url = doc.url.startsWith('http')\n ? doc.url\n : `${process.env.NEXT_PUBLIC_SERVER_URL || ''}${doc.url}`\n const response = await fetch(url)\n if (!response.ok) {\n throw new Error(`Failed to fetch file from ${url}: ${response.status} ${response.statusText}`)\n }\n return Buffer.from(await response.arrayBuffer())\n }\n\n throw new Error(`Cannot read file: no local path or URL available for \"${doc.filename}\"`)\n}\n"],"names":["fs","path","resolveStaticDir","isCloudStorage","collectionConfig","upload","disableLocalStorage","fetchFileBuffer","doc","safeFilename","filename","basename","undefined","staticDir","readFile","join","url","startsWith","process","env","NEXT_PUBLIC_SERVER_URL","response","fetch","ok","Error","status","statusText","Buffer","from","arrayBuffer"],"mappings":"AAAA,OAAOA,QAAQ,cAAa;AAC5B,OAAOC,UAAU,OAAM;AAEvB,SAASC,gBAAgB,QAAQ,wBAAuB;AAExD;;;CAGC,GACD,OAAO,SAASC,eAAeC,gBAA4D;IACzF,OAAO,OAAOA,iBAAiBC,MAAM,KAAK,YAAYD,iBAAiBC,MAAM,CAACC,mBAAmB,KAAK;AACxG;AAEA;;;;CAIC,GACD,OAAO,eAAeC,gBACpBC,GAAwC,EACxCJ,gBAA4D;IAE5D,MAAMK,eAAeD,IAAIE,QAAQ,GAAGT,KAAKU,QAAQ,CAACH,IAAIE,QAAQ,IAAIE;IAElE,4DAA4D;IAC5D,IAAI,CAACT,eAAeC,qBAAqBK,cAAc;QACrD,MAAMI,YAAYX,iBAAiBE;QACnC,IAAIS,WAAW;YACb,IAAI;gBACF,OAAO,MAAMb,GAAGc,QAAQ,CAACb,KAAKc,IAAI,CAACF,WAAWJ;YAChD,EAAE,OAAM;YACN,4BAA4B;YAC9B;QACF;IACF;IAEA,qEAAqE;IACrE,IAAID,IAAIQ,GAAG,EAAE;QACX,MAAMA,MAAMR,IAAIQ,GAAG,CAACC,UAAU,CAAC,UAC3BT,IAAIQ,GAAG,GACP,GAAGE,QAAQC,GAAG,CAACC,sBAAsB,IAAI,KAAKZ,IAAIQ,GAAG,EAAE;QAC3D,MAAMK,WAAW,MAAMC,MAAMN;QAC7B,IAAI,CAACK,SAASE,EAAE,EAAE;YAChB,MAAM,IAAIC,MAAM,CAAC,0BAA0B,EAAER,IAAI,EAAE,EAAEK,SAASI,MAAM,CAAC,CAAC,EAAEJ,SAASK,UAAU,EAAE;QAC/F;QACA,OAAOC,OAAOC,IAAI,CAAC,MAAMP,SAASQ,WAAW;IAC/C;IAEA,MAAM,IAAIL,MAAM,CAAC,sDAAsD,EAAEhB,IAAIE,QAAQ,CAAC,CAAC,CAAC;AAC1F"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inoo-ch/payload-image-optimizer",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
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": [
@@ -2,7 +2,7 @@
2
2
 
3
3
  import React from 'react'
4
4
  import { thumbHashToDataURL } from 'thumbhash'
5
- import { useAllFormFields } from '@payloadcms/ui'
5
+ import { useAllFormFields, useDocumentInfo } from '@payloadcms/ui'
6
6
 
7
7
  const formatBytes = (bytes: number): string => {
8
8
  if (bytes === 0) return '0 B'
@@ -19,15 +19,90 @@ const statusColors: Record<string, string> = {
19
19
  error: '#ef4444',
20
20
  }
21
21
 
22
+ const POLL_INTERVAL_MS = 2000
23
+
24
+ type PolledData = {
25
+ status?: string
26
+ originalSize?: number
27
+ optimizedSize?: number
28
+ thumbHash?: string
29
+ error?: string
30
+ variants?: Array<{
31
+ format?: string
32
+ filename?: string
33
+ filesize?: number
34
+ width?: number
35
+ height?: number
36
+ }>
37
+ }
38
+
22
39
  export const OptimizationStatus: React.FC<{ path?: string }> = (props) => {
23
40
  const [formState] = useAllFormFields()
41
+ const { collectionSlug, id } = useDocumentInfo()
24
42
  const basePath = props.path ?? 'imageOptimizer'
25
43
 
26
- const status = formState[`${basePath}.status`]?.value as string | undefined
27
- const originalSize = formState[`${basePath}.originalSize`]?.value as number | undefined
28
- const optimizedSize = formState[`${basePath}.optimizedSize`]?.value as number | undefined
29
- const thumbHash = formState[`${basePath}.thumbHash`]?.value as string | undefined
30
- const error = formState[`${basePath}.error`]?.value as string | undefined
44
+ const formStatus = formState[`${basePath}.status`]?.value as string | undefined
45
+ const formOriginalSize = formState[`${basePath}.originalSize`]?.value as number | undefined
46
+ const formOptimizedSize = formState[`${basePath}.optimizedSize`]?.value as number | undefined
47
+ const formThumbHash = formState[`${basePath}.thumbHash`]?.value as string | undefined
48
+ const formError = formState[`${basePath}.error`]?.value as string | undefined
49
+
50
+ const [polledData, setPolledData] = React.useState<PolledData | null>(null)
51
+
52
+ // Reset polled data when a new upload changes the form status back to pending
53
+ React.useEffect(() => {
54
+ if (formStatus === 'pending') {
55
+ setPolledData(null)
56
+ }
57
+ }, [formStatus])
58
+
59
+ // Poll for status updates when status is non-terminal
60
+ React.useEffect(() => {
61
+ const currentStatus = polledData?.status ?? formStatus
62
+ if (!currentStatus || currentStatus === 'complete' || currentStatus === 'error') return
63
+ if (!collectionSlug || !id) return
64
+
65
+ const controller = new AbortController()
66
+
67
+ const poll = async () => {
68
+ try {
69
+ const res = await fetch(`/api/${collectionSlug}/${id}?depth=0`, {
70
+ signal: controller.signal,
71
+ })
72
+ if (!res.ok) return
73
+ const doc = await res.json()
74
+ const optimizer = doc.imageOptimizer
75
+ if (!optimizer) return
76
+
77
+ setPolledData({
78
+ status: optimizer.status,
79
+ originalSize: optimizer.originalSize,
80
+ optimizedSize: optimizer.optimizedSize,
81
+ thumbHash: optimizer.thumbHash,
82
+ error: optimizer.error,
83
+ variants: optimizer.variants,
84
+ })
85
+ } catch {
86
+ // Silently ignore fetch errors (abort, network issues)
87
+ }
88
+ }
89
+
90
+ const intervalId = setInterval(poll, POLL_INTERVAL_MS)
91
+ // Run immediately on mount
92
+ poll()
93
+
94
+ return () => {
95
+ controller.abort()
96
+ clearInterval(intervalId)
97
+ }
98
+ }, [polledData?.status, formStatus, collectionSlug, id])
99
+
100
+ // Use polled data when available, otherwise fall back to form state
101
+ const status = polledData?.status ?? formStatus
102
+ const originalSize = polledData?.originalSize ?? formOriginalSize
103
+ const optimizedSize = polledData?.optimizedSize ?? formOptimizedSize
104
+ const thumbHash = polledData?.thumbHash ?? formThumbHash
105
+ const error = polledData?.error ?? formError
31
106
 
32
107
  const thumbHashUrl = React.useMemo(() => {
33
108
  if (!thumbHash) return null
@@ -39,26 +114,30 @@ export const OptimizationStatus: React.FC<{ path?: string }> = (props) => {
39
114
  }
40
115
  }, [thumbHash])
41
116
 
42
- // Read variants array from form state
43
- const variantsField = formState[`${basePath}.variants`]
44
- const rowCount = (variantsField as any)?.rows?.length ?? 0
117
+ // Read variants from polled data or form state
45
118
  const variants: Array<{
46
119
  format?: string
47
120
  filename?: string
48
121
  filesize?: number
49
122
  width?: number
50
123
  height?: number
51
- }> = []
52
-
53
- for (let i = 0; i < rowCount; i++) {
54
- variants.push({
55
- format: formState[`${basePath}.variants.${i}.format`]?.value as string | undefined,
56
- filename: formState[`${basePath}.variants.${i}.filename`]?.value as string | undefined,
57
- filesize: formState[`${basePath}.variants.${i}.filesize`]?.value as number | undefined,
58
- width: formState[`${basePath}.variants.${i}.width`]?.value as number | undefined,
59
- height: formState[`${basePath}.variants.${i}.height`]?.value as number | undefined,
60
- })
61
- }
124
+ }> = React.useMemo(() => {
125
+ if (polledData?.variants) return polledData.variants
126
+
127
+ const variantsField = formState[`${basePath}.variants`]
128
+ const rowCount = (variantsField as any)?.rows?.length ?? 0
129
+ const formVariants: typeof variants = []
130
+ for (let i = 0; i < rowCount; i++) {
131
+ formVariants.push({
132
+ format: formState[`${basePath}.variants.${i}.format`]?.value as string | undefined,
133
+ filename: formState[`${basePath}.variants.${i}.filename`]?.value as string | undefined,
134
+ filesize: formState[`${basePath}.variants.${i}.filesize`]?.value as number | undefined,
135
+ width: formState[`${basePath}.variants.${i}.width`]?.value as number | undefined,
136
+ height: formState[`${basePath}.variants.${i}.height`]?.value as number | undefined,
137
+ })
138
+ }
139
+ return formVariants
140
+ }, [polledData?.variants, formState, basePath])
62
141
 
63
142
  if (!status) {
64
143
  return (
@@ -14,7 +14,10 @@ export const createAfterChangeHook = (
14
14
  return async ({ context, doc, req }) => {
15
15
  if (context?.imageOptimizer_skip) return doc
16
16
 
17
- if (!req.file || !req.file.data || !req.file.mimetype?.startsWith('image/')) return doc
17
+ // Use context flag from beforeChange instead of checking req.file.data directly.
18
+ // Cloud storage adapters may consume req.file.data in their own afterChange hook
19
+ // before ours runs, which would cause this guard to bail out and leave status as 'pending'.
20
+ if (!context?.imageOptimizer_hasUpload) return doc
18
21
 
19
22
  const collectionConfig = req.payload.collections[collectionSlug as keyof typeof req.payload.collections].config
20
23
  const cloudStorage = isCloudStorage(collectionConfig)
@@ -52,8 +55,10 @@ export const createAfterChangeHook = (
52
55
  id: doc.id,
53
56
  data: {
54
57
  imageOptimizer: {
58
+ ...doc.imageOptimizer,
55
59
  status: 'complete',
56
60
  variants: [],
61
+ error: null,
57
62
  },
58
63
  },
59
64
  context: { imageOptimizer_skip: true },
@@ -70,8 +75,10 @@ export const createAfterChangeHook = (
70
75
  id: doc.id,
71
76
  data: {
72
77
  imageOptimizer: {
78
+ ...doc.imageOptimizer,
73
79
  status: 'complete',
74
80
  variants: [],
81
+ error: null,
75
82
  },
76
83
  },
77
84
  context: { imageOptimizer_skip: true },
@@ -67,6 +67,7 @@ export const createBeforeChangeHook = (
67
67
  req.file.mimetype = data.mimeType
68
68
  }
69
69
  context.imageOptimizer_processedBuffer = finalBuffer
70
+ context.imageOptimizer_hasUpload = true
70
71
 
71
72
  return data
72
73
  }
@@ -28,8 +28,10 @@ export const createConvertFormatsHandler = (resolvedConfig: ResolvedImageOptimiz
28
28
  id: input.docId,
29
29
  data: {
30
30
  imageOptimizer: {
31
+ ...doc.imageOptimizer,
31
32
  status: 'complete',
32
33
  variants: [],
34
+ error: null,
33
35
  },
34
36
  },
35
37
  context: { imageOptimizer_skip: true },
@@ -86,8 +88,10 @@ export const createConvertFormatsHandler = (resolvedConfig: ResolvedImageOptimiz
86
88
  id: input.docId,
87
89
  data: {
88
90
  imageOptimizer: {
91
+ ...doc.imageOptimizer,
89
92
  status: 'complete',
90
93
  variants,
94
+ error: null,
91
95
  },
92
96
  },
93
97
  context: { imageOptimizer_skip: true },