@xenterprises/fastify-ximagepipeline 1.1.1 → 1.2.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@xenterprises/fastify-ximagepipeline",
3
3
  "type": "module",
4
- "version": "1.1.1",
4
+ "version": "1.2.0",
5
5
  "description": "Fastify plugin for image uploads with EXIF stripping, moderation, variant generation, and R2 storage with job queue",
6
6
  "main": "src/xImagePipeline.js",
7
7
  "exports": {
@@ -29,7 +29,7 @@
29
29
  "webp"
30
30
  ],
31
31
  "author": "Tim Mushen",
32
- "license": "ISC",
32
+ "license": "UNLICENSED",
33
33
  "repository": {
34
34
  "type": "git",
35
35
  "url": "git@gitlab.com:x-enterprises/fastify-plugins/fastify-x-imagepipeline.git"
@@ -11,7 +11,7 @@ export async function setupStatusRoute(fastify, context) {
11
11
 
12
12
  if (!jobId) {
13
13
  return reply.status(400).send({
14
- error: "jobId parameter required",
14
+ error: "[xImagePipeline] jobId parameter required",
15
15
  });
16
16
  }
17
17
 
@@ -25,7 +25,7 @@ export async function setupStatusRoute(fastify, context) {
25
25
 
26
26
  if (!job) {
27
27
  return reply.status(404).send({
28
- error: "Job not found",
28
+ error: "[xImagePipeline] Job not found",
29
29
  });
30
30
  }
31
31
 
@@ -68,20 +68,18 @@ export async function setupStatusRoute(fastify, context) {
68
68
  // Determine HTTP status code
69
69
  let statusCode = 200;
70
70
  if (job.status === "PENDING" || job.status === "PROCESSING") {
71
- statusCode = 202; // Still processing
72
- } else if (job.status === "COMPLETE") {
73
- statusCode = 200; // Done
71
+ statusCode = 202;
74
72
  } else if (job.status === "REJECTED") {
75
- statusCode = 400; // Bad request (content rejected)
73
+ statusCode = 400;
76
74
  } else if (job.status === "FAILED") {
77
- statusCode = 500; // Server error
75
+ statusCode = 500;
78
76
  }
79
77
 
80
78
  return reply.status(statusCode).send(response);
81
79
  } catch (error) {
82
- console.error("Status check error:", error);
80
+ fastify.log.error({ err: error }, "[xImagePipeline] Status check error");
83
81
  return reply.status(500).send({
84
- error: "Failed to check job status",
82
+ error: "[xImagePipeline] Failed to check job status",
85
83
  });
86
84
  }
87
85
  });
@@ -1,5 +1,5 @@
1
1
  // src/routes/upload.js
2
- import { uploadToS3, getPublicUrl } from "../services/s3.js";
2
+ import { uploadToS3, deleteFromS3 } from "../services/s3.js";
3
3
  import { getVariantPresets } from "../xImagePipeline.js";
4
4
 
5
5
  /**
@@ -13,7 +13,7 @@ export async function setupUploadRoute(fastify, context) {
13
13
 
14
14
  if (!data) {
15
15
  return reply.status(400).send({
16
- error: "No file provided",
16
+ error: "[xImagePipeline] No file provided",
17
17
  });
18
18
  }
19
19
 
@@ -27,7 +27,7 @@ export async function setupUploadRoute(fastify, context) {
27
27
  // Validate file type
28
28
  if (!context.allowedMimeTypes.includes(mimetype)) {
29
29
  return reply.status(400).send({
30
- error: `File type ${mimetype} not allowed. Allowed types: ${context.allowedMimeTypes.join(", ")}`,
30
+ error: `[xImagePipeline] File type ${mimetype} not allowed. Allowed types: ${context.allowedMimeTypes.join(", ")}`,
31
31
  });
32
32
  }
33
33
 
@@ -37,7 +37,7 @@ export async function setupUploadRoute(fastify, context) {
37
37
 
38
38
  if (!sourceType || !sourceId) {
39
39
  return reply.status(400).send({
40
- error: "sourceType and sourceId are required",
40
+ error: "[xImagePipeline] sourceType and sourceId are required",
41
41
  });
42
42
  }
43
43
 
@@ -45,7 +45,7 @@ export async function setupUploadRoute(fastify, context) {
45
45
  const presets = getVariantPresets();
46
46
  if (!presets[sourceType]) {
47
47
  return reply.status(400).send({
48
- error: `Unknown sourceType: ${sourceType}. Allowed types: ${Object.keys(presets).join(", ")}`,
48
+ error: `[xImagePipeline] Unknown sourceType: ${sourceType}. Allowed types: ${Object.keys(presets).join(", ")}`,
49
49
  });
50
50
  }
51
51
 
@@ -55,7 +55,7 @@ export async function setupUploadRoute(fastify, context) {
55
55
  // Validate file size
56
56
  if (buffer.length > context.maxFileSize) {
57
57
  return reply.status(413).send({
58
- error: `File too large. Maximum size: ${context.maxFileSize / 1024 / 1024}MB`,
58
+ error: `[xImagePipeline] File too large. Maximum size: ${context.maxFileSize / 1024 / 1024}MB`,
59
59
  });
60
60
  }
61
61
 
@@ -75,9 +75,9 @@ export async function setupUploadRoute(fastify, context) {
75
75
  },
76
76
  });
77
77
  } catch (err) {
78
- console.error("R2 upload failed:", err.message);
78
+ fastify.log.error({ err, stagingKey }, "[xImagePipeline] R2 upload failed");
79
79
  return reply.status(500).send({
80
- error: "Failed to upload file to storage",
80
+ error: "[xImagePipeline] Failed to upload file to storage",
81
81
  });
82
82
  }
83
83
 
@@ -98,7 +98,7 @@ export async function setupUploadRoute(fastify, context) {
98
98
 
99
99
  jobId = job.id;
100
100
  } catch (err) {
101
- console.error("Database create failed:", err.message);
101
+ fastify.log.error({ err, stagingKey }, "[xImagePipeline] Database create failed");
102
102
  // Try to clean up uploaded file
103
103
  try {
104
104
  await deleteFromS3(context.s3Client, context.r2Config.bucket, stagingKey);
@@ -107,7 +107,7 @@ export async function setupUploadRoute(fastify, context) {
107
107
  }
108
108
 
109
109
  return reply.status(500).send({
110
- error: "Failed to create processing job",
110
+ error: "[xImagePipeline] Failed to create processing job",
111
111
  });
112
112
  }
113
113
 
@@ -118,15 +118,10 @@ export async function setupUploadRoute(fastify, context) {
118
118
  statusUrl: `/image-pipeline/status/${jobId}`,
119
119
  });
120
120
  } catch (error) {
121
- console.error("Upload error:", error);
121
+ fastify.log.error({ err: error }, "[xImagePipeline] Upload error");
122
122
  return reply.status(500).send({
123
- error: "Upload failed",
123
+ error: "[xImagePipeline] Upload failed",
124
124
  });
125
125
  }
126
126
  });
127
127
  }
128
-
129
- /**
130
- * Helper function to delete from S3 (imported from service)
131
- */
132
- import { deleteFromS3 } from "../services/s3.js";
@@ -4,34 +4,28 @@ import { encode } from "blurhash";
4
4
 
5
5
  /**
6
6
  * Strip EXIF data from image while preserving orientation
7
- * Ensures the image is properly rotated according to EXIF orientation before stripping
7
+ * Sharp automatically rotates based on EXIF orientation before stripping
8
+ * @param {Buffer} buffer - Image buffer
9
+ * @returns {Promise<Buffer>} Cleaned image buffer
8
10
  */
9
11
  export async function stripExif(buffer) {
10
12
  try {
11
- const image = sharp(buffer);
12
-
13
- // Get metadata including orientation
14
- const metadata = await image.metadata();
15
-
16
- // Sharp automatically rotates based on EXIF orientation, then removes EXIF
17
- // when we don't include metadata in output
18
- const result = await image
19
- .withMetadata(false) // This removes all EXIF/metadata
13
+ return await sharp(buffer)
14
+ .withMetadata(false)
20
15
  .toBuffer();
21
-
22
- return result;
23
16
  } catch (error) {
24
- throw new Error(`Failed to strip EXIF: ${error.message}`);
17
+ throw new Error(`[xImagePipeline] Failed to strip EXIF: ${error.message}`);
25
18
  }
26
19
  }
27
20
 
28
21
  /**
29
22
  * Get image metadata
23
+ * @param {Buffer} buffer - Image buffer
24
+ * @returns {Promise<{width: number, height: number, format: string, colorspace: string, hasAlpha: boolean, density: number}>}
30
25
  */
31
26
  export async function getImageMetadata(buffer) {
32
27
  try {
33
28
  const metadata = await sharp(buffer).metadata();
34
-
35
29
  return {
36
30
  width: metadata.width,
37
31
  height: metadata.height,
@@ -41,56 +35,58 @@ export async function getImageMetadata(buffer) {
41
35
  density: metadata.density,
42
36
  };
43
37
  } catch (error) {
44
- throw new Error(`Failed to get image metadata: ${error.message}`);
38
+ throw new Error(`[xImagePipeline] Failed to get image metadata: ${error.message}`);
45
39
  }
46
40
  }
47
41
 
48
42
  /**
49
43
  * Compress image to JPEG format for storage
50
- * Used for storing originals in a space-efficient format
51
44
  * @param {Buffer} buffer - Image buffer
52
- * @param {number} quality - JPEG quality (0-100, default 85)
45
+ * @param {number} [quality=85] - JPEG quality (0-100)
46
+ * @returns {Promise<Buffer>}
53
47
  */
54
48
  export async function compressToJpeg(buffer, quality = 85) {
55
49
  try {
56
- const compressed = await sharp(buffer)
50
+ return await sharp(buffer)
57
51
  .jpeg({ quality, mozjpeg: true })
58
52
  .toBuffer();
59
-
60
- return compressed;
61
53
  } catch (error) {
62
- throw new Error(`Failed to compress image to JPEG: ${error.message}`);
54
+ throw new Error(`[xImagePipeline] Failed to compress to JPEG: ${error.message}`);
63
55
  }
64
56
  }
65
57
 
66
58
  /**
67
59
  * Generate image variants in WebP format
68
- * Only generates variants where source is larger than target
60
+ * Only generates variants where source is larger than target.
61
+ * Expects a pre-cleaned buffer (EXIF already stripped).
62
+ *
63
+ * @param {Buffer} buffer - Image buffer (should already be EXIF-stripped)
64
+ * @param {Object} variantSpecs - { name: { width, height, fit } }
65
+ * @param {string} sourceType - Source type name (for logging)
66
+ * @param {number} [quality=85] - WebP quality (0-100)
67
+ * @returns {Promise<Object<string, Buffer>>} Variant name -> buffer map
69
68
  */
70
- export async function generateVariants(buffer, variantSpecs, sourceType) {
69
+ export async function generateVariants(buffer, variantSpecs, sourceType, quality = 85) {
71
70
  try {
72
- // First strip EXIF
73
- const cleanBuffer = await stripExif(buffer);
74
-
75
- // Get original dimensions
76
- const originalMetadata = await getImageMetadata(cleanBuffer);
71
+ const originalMetadata = await getImageMetadata(buffer);
77
72
  const { width: srcWidth, height: srcHeight } = originalMetadata;
78
73
 
79
74
  const variants = {};
80
75
 
81
- // Generate each variant
82
76
  for (const [variantName, spec] of Object.entries(variantSpecs)) {
83
77
  const { width: targetWidth, height: targetHeight, fit } = spec;
84
78
 
85
79
  // Skip if source is too small
86
- if (srcWidth < targetWidth || (targetHeight && srcHeight < targetHeight)) {
80
+ if (targetWidth && srcWidth < targetWidth) {
81
+ continue;
82
+ }
83
+ if (targetHeight && srcHeight < targetHeight) {
87
84
  continue;
88
85
  }
89
86
 
90
87
  try {
91
- let sharpInstance = sharp(cleanBuffer);
88
+ let sharpInstance = sharp(buffer);
92
89
 
93
- // Apply resize
94
90
  if (fit === "cover") {
95
91
  sharpInstance = sharpInstance.resize(targetWidth, targetHeight, {
96
92
  fit: "cover",
@@ -103,54 +99,55 @@ export async function generateVariants(buffer, variantSpecs, sourceType) {
103
99
  });
104
100
  }
105
101
 
106
- // Convert to WebP
107
102
  const variantBuffer = await sharpInstance
108
- .webp({ quality: 85 })
103
+ .webp({ quality })
109
104
  .toBuffer();
110
105
 
111
106
  variants[variantName] = variantBuffer;
112
107
  } catch (err) {
113
- console.error(`Failed to generate variant ${variantName}:`, err.message);
114
- // Continue with other variants
108
+ // Continue with other variants on individual failure
115
109
  }
116
110
  }
117
111
 
118
112
  return variants;
119
113
  } catch (error) {
120
- throw new Error(`Failed to generate variants: ${error.message}`);
114
+ throw new Error(`[xImagePipeline] Failed to generate variants: ${error.message}`);
121
115
  }
122
116
  }
123
117
 
124
118
  /**
125
119
  * Generate blurhash for image (used as loading placeholder)
126
- * Creates a low-quality placeholder for quick loading
120
+ * @param {Buffer} buffer - Image buffer
121
+ * @returns {Promise<string>} Blurhash string
127
122
  */
128
123
  export async function generateBlurhash(buffer) {
129
124
  try {
130
- // Resize to small thumbnail for blurhash (10x10 is typical)
131
- const thumbnail = await sharp(buffer)
125
+ const { data, info } = await sharp(buffer)
132
126
  .resize(10, 10, { fit: "inside" })
127
+ .ensureAlpha()
133
128
  .raw()
134
129
  .toBuffer({ resolveWithObject: true });
135
130
 
136
- const { data, info } = thumbnail;
137
- const blurhashValue = encode(
131
+ return encode(
138
132
  new Uint8ClampedArray(data),
139
133
  info.width,
140
134
  info.height,
141
- 4, // x components
142
- 3 // y components
135
+ 4,
136
+ 3
143
137
  );
144
-
145
- return blurhashValue;
146
138
  } catch (error) {
147
- throw new Error(`Failed to generate blurhash: ${error.message}`);
139
+ throw new Error(`[xImagePipeline] Failed to generate blurhash: ${error.message}`);
148
140
  }
149
141
  }
150
142
 
151
143
  /**
152
144
  * Calculate optimal dimensions for fit="inside"
153
145
  * Maintains aspect ratio while fitting within maxWidth x maxHeight
146
+ * @param {number} srcWidth
147
+ * @param {number} srcHeight
148
+ * @param {number} maxWidth
149
+ * @param {number} maxHeight
150
+ * @returns {{ width: number, height: number }}
154
151
  */
155
152
  export function calculateFitDimensions(srcWidth, srcHeight, maxWidth, maxHeight) {
156
153
  const aspectRatio = srcWidth / srcHeight;
@@ -158,10 +155,8 @@ export function calculateFitDimensions(srcWidth, srcHeight, maxWidth, maxHeight)
158
155
  let height = maxHeight;
159
156
 
160
157
  if (aspectRatio > maxWidth / maxHeight) {
161
- // Image is wider - fit to width
162
158
  height = Math.round(maxWidth / aspectRatio);
163
159
  } else {
164
- // Image is taller - fit to height
165
160
  width = Math.round(maxHeight * aspectRatio);
166
161
  }
167
162
 
@@ -170,6 +165,9 @@ export function calculateFitDimensions(srcWidth, srcHeight, maxWidth, maxHeight)
170
165
 
171
166
  /**
172
167
  * Get aspect ratio string (e.g., "16:9", "4:3", "1:1")
168
+ * @param {number} width
169
+ * @param {number} height
170
+ * @returns {string}
173
171
  */
174
172
  export function getAspectRatio(width, height) {
175
173
  const gcd = (a, b) => (b === 0 ? a : gcd(b, a % b));
@@ -178,15 +176,21 @@ export function getAspectRatio(width, height) {
178
176
  }
179
177
 
180
178
  /**
181
- * Validate image buffer
179
+ * Validate image buffer against constraints
180
+ * @param {Buffer} buffer - Image buffer
181
+ * @param {Object} [options]
182
+ * @param {number} [options.minWidth]
183
+ * @param {number} [options.maxWidth]
184
+ * @param {number} [options.minHeight]
185
+ * @param {number} [options.maxHeight]
186
+ * @param {string[]} [options.allowedFormats]
187
+ * @returns {Promise<{ valid: boolean, errors?: string[], metadata: Object|null }>}
182
188
  */
183
189
  export async function validateImage(buffer, options = {}) {
184
190
  try {
185
191
  const metadata = await getImageMetadata(buffer);
186
-
187
192
  const errors = [];
188
193
 
189
- // Check dimensions
190
194
  if (options.minWidth && metadata.width < options.minWidth) {
191
195
  errors.push(`Image width must be at least ${options.minWidth}px`);
192
196
  }
@@ -199,8 +203,6 @@ export async function validateImage(buffer, options = {}) {
199
203
  if (options.maxHeight && metadata.height > options.maxHeight) {
200
204
  errors.push(`Image height must not exceed ${options.maxHeight}px`);
201
205
  }
202
-
203
- // Check format
204
206
  if (options.allowedFormats && !options.allowedFormats.includes(metadata.format)) {
205
207
  errors.push(`Image format ${metadata.format} is not allowed`);
206
208
  }
@@ -220,35 +222,25 @@ export async function validateImage(buffer, options = {}) {
220
222
  }
221
223
 
222
224
  /**
223
- * Process image with configurable variants and formats
224
- * Returns all generated variants and metadata
225
+ * Process image with configurable variants and formats.
226
+ * Full pipeline: strip EXIF, extract metadata, generate variants, blurhash, optional original.
225
227
  *
226
- * @param {Buffer} buffer - Image buffer
227
- * @param {string} sourceType - Source type (avatar, background, etc.)
228
- * @param {Object} config - Configuration object
229
- * @param {Object} config.sourceTypeConfig - Source type specific config
230
- * @param {Array<string>} config.sourceTypeConfig.variants - Variant names to generate
231
- * @param {Array<string>} config.sourceTypeConfig.formats - Formats to generate ['webp', 'avif']
232
- * @param {number} config.sourceTypeConfig.quality - Quality (0-100)
233
- * @param {boolean} config.sourceTypeConfig.storeOriginal - Whether to store original
234
- * @param {Object} config.variantSpecs - Variant dimension specs
235
- * @returns {Promise<Object>} Processed image data with variants, metadata, blurhash
228
+ * @param {Buffer} buffer - Raw image buffer
229
+ * @param {string} sourceType - Source type name
230
+ * @param {Object} config - { sourceTypeConfig, variantSpecs }
231
+ * @returns {Promise<Object>} { metadata, blurhash, variants: { webp, avif? }, original? }
236
232
  */
237
233
  export async function processImage(buffer, sourceType, config) {
238
234
  try {
239
235
  const sourceTypeConfig = config.sourceTypeConfig;
240
236
 
241
237
  if (!sourceTypeConfig) {
242
- throw new Error(`No configuration found for source type: ${sourceType}`);
238
+ throw new Error(`[xImagePipeline] No configuration found for source type: ${sourceType}`);
243
239
  }
244
240
 
245
- // Step 1: Strip EXIF
246
241
  const cleanBuffer = await stripExif(buffer);
247
-
248
- // Step 2: Get metadata
249
242
  const metadata = await getImageMetadata(cleanBuffer);
250
243
 
251
- // Step 3: Generate variants
252
244
  const variantSpecs = {};
253
245
  for (const variantName of sourceTypeConfig.variants) {
254
246
  if (config.variantSpecs[variantName]) {
@@ -256,12 +248,10 @@ export async function processImage(buffer, sourceType, config) {
256
248
  }
257
249
  }
258
250
 
259
- const webpVariants = await generateVariants(cleanBuffer, variantSpecs, sourceType);
260
-
261
- // Step 4: Generate blurhash
251
+ const quality = sourceTypeConfig.quality || 85;
252
+ const webpVariants = await generateVariants(cleanBuffer, variantSpecs, sourceType, quality);
262
253
  const blurhash = await generateBlurhash(cleanBuffer);
263
254
 
264
- // Step 5: Prepare result object
265
255
  const result = {
266
256
  metadata,
267
257
  blurhash,
@@ -270,29 +260,25 @@ export async function processImage(buffer, sourceType, config) {
270
260
  },
271
261
  };
272
262
 
273
- // Step 6: Handle original (if configured)
274
263
  if (sourceTypeConfig.storeOriginal) {
275
- const originalCompressed = await compressToJpeg(cleanBuffer, sourceTypeConfig.quality);
264
+ const originalCompressed = await compressToJpeg(cleanBuffer, quality);
276
265
  result.original = {
277
- format: 'jpeg',
266
+ format: "jpeg",
278
267
  buffer: originalCompressed,
279
268
  };
280
269
  }
281
270
 
282
- // Step 7: Generate AVIF variants if configured (only for small files)
283
- if (sourceTypeConfig.formats.includes('avif')) {
271
+ if (sourceTypeConfig.formats.includes("avif")) {
284
272
  const avifVariants = {};
285
273
  for (const [variantName, webpBuffer] of Object.entries(webpVariants)) {
286
274
  try {
287
- // Only generate AVIF if WebP is under 100KB
288
275
  if (webpBuffer.length < 100 * 1024) {
289
276
  const avifBuffer = await sharp(webpBuffer)
290
- .avif({ quality: sourceTypeConfig.quality })
277
+ .avif({ quality })
291
278
  .toBuffer();
292
279
  avifVariants[variantName] = avifBuffer;
293
280
  }
294
- } catch (err) {
295
- console.warn(`Failed to generate AVIF for ${variantName}: ${err.message}`);
281
+ } catch {
296
282
  // Continue with WebP only
297
283
  }
298
284
  }
@@ -303,6 +289,6 @@ export async function processImage(buffer, sourceType, config) {
303
289
 
304
290
  return result;
305
291
  } catch (error) {
306
- throw new Error(`Failed to process image: ${error.message}`);
292
+ throw new Error(`[xImagePipeline] Failed to process image: ${error.message}`);
307
293
  }
308
294
  }