@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/LICENSE +60 -0
- package/README.md +211 -441
- package/package.json +2 -2
- package/src/routes/status.js +7 -9
- package/src/routes/upload.js +12 -17
- package/src/utils/image.js +71 -85
- package/src/workers/processor.js +39 -30
- package/src/xImagePipeline.js +93 -22
- package/SCHEMA.prisma +0 -113
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xenterprises/fastify-ximagepipeline",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "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": "
|
|
32
|
+
"license": "UNLICENSED",
|
|
33
33
|
"repository": {
|
|
34
34
|
"type": "git",
|
|
35
35
|
"url": "git@gitlab.com:x-enterprises/fastify-plugins/fastify-x-imagepipeline.git"
|
package/src/routes/status.js
CHANGED
|
@@ -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;
|
|
72
|
-
} else if (job.status === "COMPLETE") {
|
|
73
|
-
statusCode = 200; // Done
|
|
71
|
+
statusCode = 202;
|
|
74
72
|
} else if (job.status === "REJECTED") {
|
|
75
|
-
statusCode = 400;
|
|
73
|
+
statusCode = 400;
|
|
76
74
|
} else if (job.status === "FAILED") {
|
|
77
|
-
statusCode = 500;
|
|
75
|
+
statusCode = 500;
|
|
78
76
|
}
|
|
79
77
|
|
|
80
78
|
return reply.status(statusCode).send(response);
|
|
81
79
|
} catch (error) {
|
|
82
|
-
|
|
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
|
});
|
package/src/routes/upload.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/routes/upload.js
|
|
2
|
-
import { uploadToS3,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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";
|
package/src/utils/image.js
CHANGED
|
@@ -4,34 +4,28 @@ import { encode } from "blurhash";
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Strip EXIF data from image while preserving orientation
|
|
7
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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(
|
|
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
|
|
103
|
+
.webp({ quality })
|
|
109
104
|
.toBuffer();
|
|
110
105
|
|
|
111
106
|
variants[variantName] = variantBuffer;
|
|
112
107
|
} catch (err) {
|
|
113
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
137
|
-
const blurhashValue = encode(
|
|
131
|
+
return encode(
|
|
138
132
|
new Uint8ClampedArray(data),
|
|
139
133
|
info.width,
|
|
140
134
|
info.height,
|
|
141
|
-
4,
|
|
142
|
-
3
|
|
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
|
-
*
|
|
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 -
|
|
227
|
-
* @param {string} sourceType - Source type
|
|
228
|
-
* @param {Object} config -
|
|
229
|
-
* @
|
|
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
|
|
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,
|
|
264
|
+
const originalCompressed = await compressToJpeg(cleanBuffer, quality);
|
|
276
265
|
result.original = {
|
|
277
|
-
format:
|
|
266
|
+
format: "jpeg",
|
|
278
267
|
buffer: originalCompressed,
|
|
279
268
|
};
|
|
280
269
|
}
|
|
281
270
|
|
|
282
|
-
|
|
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
|
|
277
|
+
.avif({ quality })
|
|
291
278
|
.toBuffer();
|
|
292
279
|
avifVariants[variantName] = avifBuffer;
|
|
293
280
|
}
|
|
294
|
-
} catch
|
|
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
|
}
|