@inoo-ch/payload-image-optimizer 1.8.0 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,17 +4,27 @@ import { resolveCollectionConfig } from '../defaults.js';
4
4
  import { convertFormat, generateThumbHash, stripAndResize } from '../processing/index.js';
5
5
  import { isCloudStorage } from '../utilities/storage.js';
6
6
  export const createBeforeChangeHook = (resolvedConfig, collectionSlug)=>{
7
- return async ({ context, data, req })=>{
7
+ return async ({ context, data, originalDoc, req })=>{
8
8
  if (context?.imageOptimizer_skip) return data;
9
9
  if (!req.file || !req.file.data || !req.file.mimetype?.startsWith('image/')) return data;
10
10
  // Rename file to UUID before any processing, so the storage adapter
11
11
  // never sees the original filename. Prevents Vercel Blob "already exists"
12
12
  // errors and avoids leaking original filenames to storage.
13
+ // On focal-point or crop re-uploads (where Payload re-sends the same file),
14
+ // reuse the existing UUID filename to avoid unnecessary file churn and
15
+ // broken previews.
13
16
  if (resolvedConfig.uniqueFileNames) {
14
- const ext = path.extname(req.file.name);
15
- const uuid = crypto.randomUUID();
16
- req.file.name = `${uuid}${ext}`;
17
- data.filename = req.file.name;
17
+ const existingFilename = originalDoc?.filename;
18
+ if (existingFilename) {
19
+ // Reuse the existing filename (may get a new extension below if replaceOriginal changes format)
20
+ req.file.name = existingFilename;
21
+ data.filename = existingFilename;
22
+ } else {
23
+ const ext = path.extname(req.file.name);
24
+ const uuid = crypto.randomUUID();
25
+ req.file.name = `${uuid}${ext}`;
26
+ data.filename = req.file.name;
27
+ }
18
28
  }
19
29
  const originalSize = req.file.data.length;
20
30
  const perCollectionConfig = resolveCollectionConfig(resolvedConfig, collectionSlug);
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/hooks/beforeChange.ts"],"sourcesContent":["import crypto from 'crypto'\nimport 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'\nimport { isCloudStorage } from '../utilities/storage.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 // Rename file to UUID before any processing, so the storage adapter\n // never sees the original filename. Prevents Vercel Blob \"already exists\"\n // errors and avoids leaking original filenames to storage.\n if (resolvedConfig.uniqueFileNames) {\n const ext = path.extname(req.file.name)\n const uuid = crypto.randomUUID()\n req.file.name = `${uuid}${ext}`\n data.filename = req.file.name\n }\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 // Determine if async work (variant generation job) is needed after create.\n // If not, set status to 'complete' now so afterChange doesn't need a separate\n // update() call — which fails with 404 on MongoDB due to transaction isolation\n // when cloud storage adapters are involved.\n const collectionConfig = req.payload.collections[collectionSlug as keyof typeof req.payload.collections].config\n const cloudStorage = isCloudStorage(collectionConfig)\n const needsAsyncJob = !cloudStorage && perCollectionConfig.formats.length > 0 && !(perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length <= 1)\n\n data.imageOptimizer = {\n originalSize,\n optimizedSize: finalSize,\n status: needsAsyncJob ? 'pending' : 'complete',\n variants: needsAsyncJob ? undefined : [],\n error: null,\n }\n\n if (!needsAsyncJob) {\n context.imageOptimizer_statusResolved = true\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":["crypto","path","resolveCollectionConfig","convertFormat","generateThumbHash","stripAndResize","isCloudStorage","createBeforeChangeHook","resolvedConfig","collectionSlug","context","data","req","imageOptimizer_skip","file","mimetype","startsWith","uniqueFileNames","ext","extname","name","uuid","randomUUID","filename","originalSize","length","perCollectionConfig","processed","maxDimensions","stripMetadata","finalBuffer","buffer","finalSize","size","replaceOriginal","formats","primaryFormat","converted","format","quality","originalFilename","newFilename","parse","imageOptimizer_originalFilename","mimeType","filesize","collectionConfig","payload","collections","config","cloudStorage","needsAsyncJob","imageOptimizer","optimizedSize","status","variants","undefined","error","imageOptimizer_statusResolved","thumbHash","imageOptimizer_processedBuffer","imageOptimizer_hasUpload"],"mappings":"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"}
1
+ {"version":3,"sources":["../../src/hooks/beforeChange.ts"],"sourcesContent":["import crypto from 'crypto'\nimport 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'\nimport { isCloudStorage } from '../utilities/storage.js'\n\nexport const createBeforeChangeHook = (\n resolvedConfig: ResolvedImageOptimizerConfig,\n collectionSlug: string,\n): CollectionBeforeChangeHook => {\n return async ({ context, data, originalDoc, 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 // Rename file to UUID before any processing, so the storage adapter\n // never sees the original filename. Prevents Vercel Blob \"already exists\"\n // errors and avoids leaking original filenames to storage.\n // On focal-point or crop re-uploads (where Payload re-sends the same file),\n // reuse the existing UUID filename to avoid unnecessary file churn and\n // broken previews.\n if (resolvedConfig.uniqueFileNames) {\n const existingFilename = (originalDoc as Record<string, unknown> | undefined)?.filename as string | undefined\n if (existingFilename) {\n // Reuse the existing filename (may get a new extension below if replaceOriginal changes format)\n req.file.name = existingFilename\n data.filename = existingFilename\n } else {\n const ext = path.extname(req.file.name)\n const uuid = crypto.randomUUID()\n req.file.name = `${uuid}${ext}`\n data.filename = req.file.name\n }\n }\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 // Determine if async work (variant generation job) is needed after create.\n // If not, set status to 'complete' now so afterChange doesn't need a separate\n // update() call — which fails with 404 on MongoDB due to transaction isolation\n // when cloud storage adapters are involved.\n const collectionConfig = req.payload.collections[collectionSlug as keyof typeof req.payload.collections].config\n const cloudStorage = isCloudStorage(collectionConfig)\n const needsAsyncJob = !cloudStorage && perCollectionConfig.formats.length > 0 && !(perCollectionConfig.replaceOriginal && perCollectionConfig.formats.length <= 1)\n\n data.imageOptimizer = {\n originalSize,\n optimizedSize: finalSize,\n status: needsAsyncJob ? 'pending' : 'complete',\n variants: needsAsyncJob ? undefined : [],\n error: null,\n }\n\n if (!needsAsyncJob) {\n context.imageOptimizer_statusResolved = true\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":["crypto","path","resolveCollectionConfig","convertFormat","generateThumbHash","stripAndResize","isCloudStorage","createBeforeChangeHook","resolvedConfig","collectionSlug","context","data","originalDoc","req","imageOptimizer_skip","file","mimetype","startsWith","uniqueFileNames","existingFilename","filename","name","ext","extname","uuid","randomUUID","originalSize","length","perCollectionConfig","processed","maxDimensions","stripMetadata","finalBuffer","buffer","finalSize","size","replaceOriginal","formats","primaryFormat","converted","format","quality","originalFilename","newFilename","parse","imageOptimizer_originalFilename","mimeType","filesize","collectionConfig","payload","collections","config","cloudStorage","needsAsyncJob","imageOptimizer","optimizedSize","status","variants","undefined","error","imageOptimizer_statusResolved","thumbHash","imageOptimizer_processedBuffer","imageOptimizer_hasUpload"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inoo-ch/payload-image-optimizer",
3
- "version": "1.8.0",
3
+ "version": "1.8.1",
4
4
  "description": "Payload CMS plugin for automatic image optimization — WebP/AVIF conversion, resize, EXIF strip, ThumbHash placeholders, and bulk regeneration",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -11,7 +11,7 @@ export const createBeforeChangeHook = (
11
11
  resolvedConfig: ResolvedImageOptimizerConfig,
12
12
  collectionSlug: string,
13
13
  ): CollectionBeforeChangeHook => {
14
- return async ({ context, data, req }) => {
14
+ return async ({ context, data, originalDoc, req }) => {
15
15
  if (context?.imageOptimizer_skip) return data
16
16
 
17
17
  if (!req.file || !req.file.data || !req.file.mimetype?.startsWith('image/')) return data
@@ -19,11 +19,21 @@ export const createBeforeChangeHook = (
19
19
  // Rename file to UUID before any processing, so the storage adapter
20
20
  // never sees the original filename. Prevents Vercel Blob "already exists"
21
21
  // errors and avoids leaking original filenames to storage.
22
+ // On focal-point or crop re-uploads (where Payload re-sends the same file),
23
+ // reuse the existing UUID filename to avoid unnecessary file churn and
24
+ // broken previews.
22
25
  if (resolvedConfig.uniqueFileNames) {
23
- const ext = path.extname(req.file.name)
24
- const uuid = crypto.randomUUID()
25
- req.file.name = `${uuid}${ext}`
26
- data.filename = req.file.name
26
+ const existingFilename = (originalDoc as Record<string, unknown> | undefined)?.filename as string | undefined
27
+ if (existingFilename) {
28
+ // Reuse the existing filename (may get a new extension below if replaceOriginal changes format)
29
+ req.file.name = existingFilename
30
+ data.filename = existingFilename
31
+ } else {
32
+ const ext = path.extname(req.file.name)
33
+ const uuid = crypto.randomUUID()
34
+ req.file.name = `${uuid}${ext}`
35
+ data.filename = req.file.name
36
+ }
27
37
  }
28
38
 
29
39
  const originalSize = req.file.data.length