u
This commit is contained in:
163
src/services/imageCropWorker.js
Normal file
163
src/services/imageCropWorker.js
Normal file
@@ -0,0 +1,163 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Worker-thread entry point for the sharp crop/trim/resize/AVIF pipeline.
|
||||
*
|
||||
* This pipeline runs up to six sequential sharp passes (PNG intermediate, trim,
|
||||
* extract, resize, AVIF encode) whose JS glue cost lands on the thread that owns
|
||||
* the pipeline. Running it here keeps that cost off the main event loop, which
|
||||
* otherwise stalls HTTP handlers and every SSE client (heartbeats included).
|
||||
*
|
||||
* Protocol: the parent sends `{ id, buffer }` (an ArrayBuffer, transferred) and
|
||||
* the worker replies `{ id, ok: true, buffer, width, height, size }` (buffer
|
||||
* transferred back) or `{ id, ok: false, error, errorName }`.
|
||||
*/
|
||||
|
||||
import { parentPort } from 'worker_threads';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const MAX_EDGE = 1000;
|
||||
const TRIM_THRESHOLD = 10;
|
||||
const RESTORE_MARGIN = 5; // px restored per edge after trimming (up to what was trimmed)
|
||||
const AVIF_QUALITY = 80;
|
||||
const AVIF_EFFORT = 4;
|
||||
|
||||
/**
|
||||
* Copies a Buffer into a standalone, transferable ArrayBuffer.
|
||||
*
|
||||
* `Buffer.from(buffer)` / `Uint8Array.prototype.subarray` can alias the shared
|
||||
* 8 KB Buffer pool, and transferring pooled memory would corrupt every other
|
||||
* Buffer in the process. A fresh `Uint8Array` is always backed by its own
|
||||
* ArrayBuffer, so it is always safe to hand to `postMessage(..., [transfer])`.
|
||||
*
|
||||
* @param {Buffer} buffer
|
||||
* @returns {ArrayBuffer}
|
||||
*/
|
||||
function toTransferable(buffer) {
|
||||
return Uint8Array.from(buffer).buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trims transparent and white borders from an image buffer, restores up to
|
||||
* RESTORE_MARGIN px of the original image on each edge (never more than was
|
||||
* actually trimmed, never enlarging beyond the original), resizes to max 1000px,
|
||||
* and converts to AVIF.
|
||||
*
|
||||
* @param {Buffer} inputBuffer - Raw image buffer
|
||||
* @returns {Promise<{ buffer: Buffer, width: number, height: number, size: number }>}
|
||||
*/
|
||||
async function cropAndConvertToAvif(inputBuffer) {
|
||||
// 1. Initial metadata and orientation correction
|
||||
const baseSharp = sharp(inputBuffer, { failOn: 'none' }).rotate();
|
||||
const initialMeta = await baseSharp.metadata();
|
||||
const hasAlpha = Boolean(initialMeta.hasAlpha);
|
||||
|
||||
// Use PNG intermediate for trimming passes to preserve alpha
|
||||
const originalBuffer = await baseSharp.png().toBuffer();
|
||||
const origMeta = await sharp(originalBuffer).metadata();
|
||||
|
||||
// Cumulative crop box in original (oriented) pixel coordinates
|
||||
let box = { left: 0, top: 0, width: origMeta.width, height: origMeta.height };
|
||||
let currentBuffer = originalBuffer;
|
||||
|
||||
const trimPass = async (background) => {
|
||||
try {
|
||||
const trimmed = await sharp(currentBuffer)
|
||||
.trim({ background, threshold: TRIM_THRESHOLD })
|
||||
.png()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
if (trimmed.info.width > 0 && trimmed.info.height > 0) {
|
||||
currentBuffer = trimmed.data;
|
||||
box = {
|
||||
left: box.left - (trimmed.info.trimOffsetLeft ?? 0),
|
||||
top: box.top - (trimmed.info.trimOffsetTop ?? 0),
|
||||
width: trimmed.info.width,
|
||||
height: trimmed.info.height,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Keep currentBuffer
|
||||
}
|
||||
};
|
||||
|
||||
// 2. Step A: Trim transparent borders if alpha is present
|
||||
if (hasAlpha) await trimPass({ r: 0, g: 0, b: 0, alpha: 0 });
|
||||
|
||||
// 3. Step B: Trim white borders
|
||||
await trimPass('#ffffff');
|
||||
|
||||
// 4. Step C: Trim transparent borders again (handles white border containing transparent padding)
|
||||
if (hasAlpha) await trimPass({ r: 0, g: 0, b: 0, alpha: 0 });
|
||||
|
||||
// 4b. Restore up to RESTORE_MARGIN px per edge from the original, clamped to original bounds
|
||||
const left = Math.max(0, box.left - RESTORE_MARGIN);
|
||||
const top = Math.max(0, box.top - RESTORE_MARGIN);
|
||||
const right = Math.min(origMeta.width, box.left + box.width + RESTORE_MARGIN);
|
||||
const bottom = Math.min(origMeta.height, box.top + box.height + RESTORE_MARGIN);
|
||||
if (left !== box.left || top !== box.top || right - left !== box.width || bottom - top !== box.height) {
|
||||
try {
|
||||
const restored = await sharp(originalBuffer)
|
||||
.extract({ left, top, width: right - left, height: bottom - top })
|
||||
.png()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
currentBuffer = restored.data;
|
||||
} catch {
|
||||
// Keep currentBuffer
|
||||
}
|
||||
}
|
||||
const currentMeta = await sharp(currentBuffer).metadata();
|
||||
|
||||
// 5. Resize to max 1000px without enlargement
|
||||
let finalPipeline = sharp(currentBuffer);
|
||||
if (currentMeta.width > MAX_EDGE || currentMeta.height > MAX_EDGE) {
|
||||
finalPipeline = finalPipeline.resize(MAX_EDGE, MAX_EDGE, {
|
||||
fit: 'inside',
|
||||
withoutEnlargement: true,
|
||||
});
|
||||
}
|
||||
|
||||
// 6. Convert to AVIF
|
||||
const avifResult = await finalPipeline
|
||||
.avif({ quality: AVIF_QUALITY, effort: AVIF_EFFORT })
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
|
||||
return {
|
||||
buffer: avifResult.data,
|
||||
width: avifResult.info.width,
|
||||
height: avifResult.info.height,
|
||||
size: avifResult.info.size,
|
||||
};
|
||||
}
|
||||
|
||||
// Boot handshake. The parent awaits this before queuing the first job, so a worker
|
||||
// whose imports (sharp/libvips) fail surfaces as a start-up error rather than as an
|
||||
// inexplicable per-job failure.
|
||||
parentPort.postMessage({ ready: true });
|
||||
|
||||
parentPort.on('message', async (msg) => {
|
||||
const { id, buffer } = msg ?? {};
|
||||
|
||||
try {
|
||||
const result = await cropAndConvertToAvif(Buffer.from(buffer));
|
||||
const out = toTransferable(result.buffer);
|
||||
parentPort.postMessage(
|
||||
{
|
||||
id,
|
||||
ok: true,
|
||||
buffer: out,
|
||||
width: result.width,
|
||||
height: result.height,
|
||||
size: result.size,
|
||||
},
|
||||
[out]
|
||||
);
|
||||
} catch (err) {
|
||||
parentPort.postMessage({
|
||||
id,
|
||||
ok: false,
|
||||
error: err?.message || String(err),
|
||||
errorName: err?.name || 'Error',
|
||||
stack: err?.stack,
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user