Sharp-based image processing worker with pipeline support
This commit is contained in:
232
src/workers/image.worker.js
Normal file
232
src/workers/image.worker.js
Normal file
@@ -0,0 +1,232 @@
|
||||
const sharp = require("sharp");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const { nanoid } = require("nanoid");
|
||||
const imageQueue = require("../config/queue");
|
||||
const wsManager = require("../utils/ws");
|
||||
const config = require("../config");
|
||||
|
||||
// Ensure output dir exists
|
||||
fs.mkdirSync(config.storage.outputDir, { recursive: true });
|
||||
|
||||
/**
|
||||
* Apply a single operation to a sharp pipeline.
|
||||
* Returns the modified pipeline (sharp is chainable).
|
||||
*/
|
||||
function applyOperation(pipeline, op, params) {
|
||||
switch (op) {
|
||||
case "resize":
|
||||
return pipeline.resize({
|
||||
width: params.width || null,
|
||||
height: params.height || null,
|
||||
fit: params.fit || "cover",
|
||||
withoutEnlargement: params.withoutEnlargement !== false,
|
||||
});
|
||||
|
||||
case "convert":
|
||||
return pipeline.toFormat(params.format, {
|
||||
quality: params.quality || 80,
|
||||
});
|
||||
|
||||
case "compress": {
|
||||
// Auto-detect format and compress
|
||||
const q = params.quality || 75;
|
||||
return pipeline
|
||||
.jpeg({ quality: q, mozjpeg: true })
|
||||
.png({ quality: q, compressionLevel: 9 })
|
||||
.webp({ quality: q })
|
||||
.avif({ quality: q });
|
||||
}
|
||||
|
||||
case "watermark": {
|
||||
const text = params.text;
|
||||
const fontSize = params.fontSize || 48;
|
||||
const opacity = params.opacity || 0.3;
|
||||
const color = params.color || "white";
|
||||
|
||||
// SVG text overlay
|
||||
const svg = `
|
||||
<svg width="1000" height="200">
|
||||
<style>
|
||||
text {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-size: ${fontSize}px;
|
||||
font-weight: bold;
|
||||
fill: ${color};
|
||||
opacity: ${opacity};
|
||||
}
|
||||
</style>
|
||||
<text x="50%" y="50%" text-anchor="middle" dominant-baseline="middle">${text}</text>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
return pipeline.composite([
|
||||
{
|
||||
input: Buffer.from(svg),
|
||||
gravity: params.position || "southeast",
|
||||
tile: !!params.tile,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
case "strip-meta":
|
||||
return pipeline.withMetadata(false);
|
||||
|
||||
case "blur":
|
||||
return pipeline.blur(params.sigma || 3);
|
||||
|
||||
case "grayscale":
|
||||
return pipeline.grayscale();
|
||||
|
||||
case "rotate":
|
||||
return pipeline.rotate(params.angle, {
|
||||
background: params.background || { r: 0, g: 0, b: 0, alpha: 0 },
|
||||
});
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown operation: ${op}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine output file extension based on operations.
|
||||
* If a 'convert' op exists, use that format. Otherwise keep original.
|
||||
*/
|
||||
function resolveOutputExt(operations, originalName) {
|
||||
const convertOp = [...operations].reverse().find((o) => o.op === "convert");
|
||||
if (convertOp) {
|
||||
const fmt = convertOp.params.format;
|
||||
return fmt === "jpeg" ? ".jpg" : `.${fmt}`;
|
||||
}
|
||||
return path.extname(originalName).toLowerCase() || ".jpg";
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a job: run all operations sequentially through a sharp pipeline.
|
||||
*/
|
||||
async function processJob(job) {
|
||||
const { inputPath, originalName, operations } = job.data;
|
||||
const totalOps = operations.length;
|
||||
|
||||
const notify = (type, data) => {
|
||||
wsManager.notify(String(job.id), { type, ...data });
|
||||
};
|
||||
|
||||
notify("started", { operations: operations.map((o) => o.op) });
|
||||
|
||||
try {
|
||||
// Get input file info
|
||||
const inputMeta = await sharp(inputPath).metadata();
|
||||
notify("info", {
|
||||
input: {
|
||||
width: inputMeta.width,
|
||||
height: inputMeta.height,
|
||||
format: inputMeta.format,
|
||||
size: fs.statSync(inputPath).size,
|
||||
},
|
||||
});
|
||||
|
||||
// Build pipeline
|
||||
let pipeline = sharp(inputPath);
|
||||
let hasConvert = false;
|
||||
|
||||
for (let i = 0; i < operations.length; i++) {
|
||||
const { op, params = {} } = operations[i];
|
||||
const stage = `${op} (${i + 1}/${totalOps})`;
|
||||
|
||||
notify("progress", {
|
||||
stage,
|
||||
progress: Math.round(((i) / totalOps) * 100),
|
||||
});
|
||||
|
||||
// Special handling: compress needs format context
|
||||
if (op === "compress" && !hasConvert) {
|
||||
// Apply compression matching input format
|
||||
const q = params.quality || 75;
|
||||
const fmtMap = {
|
||||
jpeg: () => pipeline.jpeg({ quality: q, mozjpeg: true }),
|
||||
png: () => pipeline.png({ quality: q, compressionLevel: 9 }),
|
||||
webp: () => pipeline.webp({ quality: q }),
|
||||
avif: () => pipeline.avif({ quality: q }),
|
||||
tiff: () => pipeline.tiff({ quality: q }),
|
||||
};
|
||||
const fn = fmtMap[inputMeta.format];
|
||||
if (fn) pipeline = fn();
|
||||
} else {
|
||||
pipeline = applyOperation(pipeline, op, params);
|
||||
if (op === "convert") hasConvert = true;
|
||||
}
|
||||
|
||||
// Report progress
|
||||
await job.progress(Math.round(((i + 1) / totalOps) * 100));
|
||||
}
|
||||
|
||||
// Write output
|
||||
const ext = resolveOutputExt(operations, originalName);
|
||||
const baseName = path.basename(originalName, path.extname(originalName));
|
||||
const outputFilename = `${baseName}_processed_${nanoid(6)}${ext}`;
|
||||
const outputPath = path.join(config.storage.outputDir, outputFilename);
|
||||
|
||||
await pipeline.toFile(outputPath);
|
||||
|
||||
// Get output stats
|
||||
const outputStats = fs.statSync(outputPath);
|
||||
const outputMeta = await sharp(outputPath).metadata();
|
||||
|
||||
const result = {
|
||||
outputPath,
|
||||
outputFilename,
|
||||
output: {
|
||||
width: outputMeta.width,
|
||||
height: outputMeta.height,
|
||||
format: outputMeta.format,
|
||||
size: outputStats.size,
|
||||
},
|
||||
savings: {
|
||||
bytes: fs.statSync(inputPath).size - outputStats.size,
|
||||
percent: Math.round(
|
||||
((fs.statSync(inputPath).size - outputStats.size) /
|
||||
fs.statSync(inputPath).size) *
|
||||
100
|
||||
),
|
||||
},
|
||||
processingTime: Date.now() - job.timestamp,
|
||||
};
|
||||
|
||||
notify("completed", result);
|
||||
|
||||
// Clean up input file
|
||||
fs.unlinkSync(inputPath);
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
notify("failed", { error: err.message });
|
||||
// Clean up input on failure too
|
||||
if (fs.existsSync(inputPath)) fs.unlinkSync(inputPath);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the worker — called from either the standalone worker
|
||||
* process or from the main API process.
|
||||
*/
|
||||
function startWorker() {
|
||||
imageQueue.process("process", config.worker.concurrency, processJob);
|
||||
|
||||
imageQueue.on("completed", (job) => {
|
||||
console.log(`[Worker] Job ${job.id} completed in ${Date.now() - job.timestamp}ms`);
|
||||
});
|
||||
|
||||
imageQueue.on("failed", (job, err) => {
|
||||
console.error(`[Worker] Job ${job.id} failed (attempt ${job.attemptsMade}):`, err.message);
|
||||
});
|
||||
|
||||
imageQueue.on("stalled", (jobId) => {
|
||||
console.warn(`[Worker] Job ${jobId} stalled — will be retried`);
|
||||
});
|
||||
|
||||
console.log(`[Worker] Processing with concurrency=${config.worker.concurrency}`);
|
||||
}
|
||||
|
||||
module.exports = { startWorker };
|
||||
13
src/workers/standalone.js
Normal file
13
src/workers/standalone.js
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Standalone worker process.
|
||||
* Run separately with: pm2 start ecosystem.config.js --only imgforge-worker
|
||||
*
|
||||
* This connects to the same Redis queue and processes jobs independently
|
||||
* of the API server. Use this when you want to scale workers separately.
|
||||
*/
|
||||
|
||||
require("../config"); // load env
|
||||
const { startWorker } = require("./image.worker");
|
||||
|
||||
console.log("[Worker] Starting standalone worker process...");
|
||||
startWorker();
|
||||
Reference in New Issue
Block a user