u
This commit is contained in:
@@ -1,12 +1,302 @@
|
||||
import sharp from 'sharp';
|
||||
import { Worker } from 'worker_threads';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const FETCH_TIMEOUT_MS = 15000;
|
||||
const MAX_BYTES = 50 * 1024 * 1024; // 50MB
|
||||
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;
|
||||
|
||||
// The crop/trim/resize/AVIF pipeline runs up to six sequential sharp passes (PNG
|
||||
// intermediate, trim, extract, resize, AVIF encode). A sharp pipeline is owned by
|
||||
// whichever thread created it, so running it on the main thread stalls every HTTP
|
||||
// handler and every SSE client (heartbeats included). The pipeline therefore lives
|
||||
// in imageCropWorker.js and is reached through the small pool below.
|
||||
const WORKER_PATH = fileURLToPath(new URL('./imageCropWorker.js', import.meta.url));
|
||||
const WORKER_BOOT_TIMEOUT_MS = 15000;
|
||||
const DEFAULT_JOB_TIMEOUT_MS = 30000;
|
||||
// libvips keeps its own warm thread pool per process, so one worker per CPU is
|
||||
// plenty; sharp's internal concurrency is what actually saturates a worker.
|
||||
const POOL_SIZE = Math.max(1, Math.min(4, (process.availableParallelism?.() ?? 2) - 1));
|
||||
|
||||
/** @type {Array<{ worker: Worker, busy: boolean, id: number }>} */
|
||||
const pool = [];
|
||||
/** @type {Array<{ resolve: Function, reject: Function, timer: NodeJS.Timeout, inputBuffer: Buffer, timeoutMs: number }>} */
|
||||
const queue = [];
|
||||
let shuttingDown = false;
|
||||
let workerSeq = 0;
|
||||
/** Serializes pool growth so concurrent callers cannot overshoot POOL_SIZE. */
|
||||
let growing = Promise.resolve();
|
||||
|
||||
/**
|
||||
* Copies a Buffer into a standalone, transferable ArrayBuffer.
|
||||
*
|
||||
* `Buffer.from(buffer)` and `Uint8Array.prototype.subarray` can alias the shared
|
||||
* 8 KB Buffer pool, and transferring pooled memory would detach it for every other
|
||||
* Buffer in the process (including in-flight HTTP bodies). A fresh `Uint8Array` is
|
||||
* always backed by its own ArrayBuffer, so it is always safe to transfer.
|
||||
*
|
||||
* @param {Buffer} buffer
|
||||
* @returns {ArrayBuffer}
|
||||
*/
|
||||
function toTransferable(buffer) {
|
||||
return Uint8Array.from(buffer).buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawns a worker and resolves once it has booted far enough to accept a job.
|
||||
* @returns {Promise<{ worker: Worker, busy: boolean, id: number }>}
|
||||
*/
|
||||
function spawnWorker() {
|
||||
const entry = { worker: null, busy: false, id: ++workerSeq };
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const bootTimer = setTimeout(() => {
|
||||
cleanup();
|
||||
try { entry.worker?.terminate(); } catch { /* already closed */ }
|
||||
reject(new Error(`Image crop worker failed to start within ${WORKER_BOOT_TIMEOUT_MS} ms`));
|
||||
}, WORKER_BOOT_TIMEOUT_MS);
|
||||
|
||||
function cleanup() {
|
||||
clearTimeout(bootTimer);
|
||||
entry.worker?.off('message', onBooted);
|
||||
entry.worker?.off('error', onError);
|
||||
entry.worker?.off('exit', onExit);
|
||||
}
|
||||
|
||||
function onBooted(msg) {
|
||||
// The worker sends an explicit `{ ready: true }` once its imports (notably
|
||||
// sharp/libvips) have resolved. Without this handshake there is no first
|
||||
// message to await, so a worker that fails to boot would only surface later
|
||||
// as a confusing per-job failure.
|
||||
if (!msg || msg.ready !== true) return;
|
||||
cleanup();
|
||||
resolve(entry);
|
||||
}
|
||||
|
||||
function onError(err) {
|
||||
cleanup();
|
||||
reject(err instanceof Error ? err : new Error(err?.message || 'Image crop worker failed to start'));
|
||||
}
|
||||
|
||||
function onExit(code) {
|
||||
cleanup();
|
||||
reject(new Error(`Image crop worker exited before becoming ready (code ${code})`));
|
||||
}
|
||||
|
||||
try {
|
||||
entry.worker = new Worker(WORKER_PATH);
|
||||
} catch (err) {
|
||||
cleanup();
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
entry.worker.on('message', onBooted);
|
||||
entry.worker.on('error', onError);
|
||||
entry.worker.on('exit', onExit);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an idle worker, spawning one when the pool is below capacity.
|
||||
*
|
||||
* When spawning fails the oldest queued job is failed with that error rather than
|
||||
* being left waiting on a worker that will never arrive — otherwise a boot failure
|
||||
* would deadlock the queue instead of surfacing to the HTTP route.
|
||||
*
|
||||
* @returns {Promise<{ worker: Worker, busy: boolean, id: number }>}
|
||||
*/
|
||||
async function acquireWorker() {
|
||||
const idle = pool.find((entry) => !entry.busy);
|
||||
if (idle) return idle;
|
||||
|
||||
if (pool.length < POOL_SIZE) {
|
||||
// Serialize pool growth. Without this, N concurrent callers all observe
|
||||
// `pool.length < POOL_SIZE` and each spawn a worker, overshooting the cap.
|
||||
growing = growing.then(async () => {
|
||||
if (pool.length >= POOL_SIZE) return;
|
||||
try {
|
||||
pool.push(await spawnWorker());
|
||||
} catch (err) {
|
||||
const waiting = queue[0];
|
||||
if (!waiting) throw err;
|
||||
// Fail the oldest queued job rather than leaving it waiting on a worker
|
||||
// that will never arrive — otherwise a boot failure deadlocks the queue
|
||||
// instead of surfacing to the HTTP route.
|
||||
queue.shift();
|
||||
clearTimeout(waiting.timer);
|
||||
waiting.reject(err);
|
||||
}
|
||||
});
|
||||
try {
|
||||
await growing;
|
||||
} catch {
|
||||
// Already reported to the queued job; continue so this caller still queues.
|
||||
}
|
||||
// Re-check: an idle worker may now exist, or the pool may still be saturated.
|
||||
if (pool.some((entry) => !entry.busy)) return;
|
||||
}
|
||||
|
||||
// Every worker is busy and the pool is at capacity. enqueueJob/drainQueue hand the
|
||||
// job the next free worker; acquiring one here is only needed to grow the pool.
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one job on a worker and resolves with its reply.
|
||||
* @param {{ worker: Worker, busy: boolean, id: number }} entry
|
||||
* @param {Buffer} inputBuffer
|
||||
* @param {number} timeoutMs
|
||||
*/
|
||||
function runJob(entry, inputBuffer, timeoutMs) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const jobId = `${entry.id}:${process.hrtime.bigint()}`;
|
||||
const transfer = toTransferable(inputBuffer);
|
||||
let settled = false;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
// Terminate first, then report. Leaving a worker blocked in libvips would let
|
||||
// it keep burning a core long after the HTTP request has been answered, and
|
||||
// its eventual reply would then arrive against an already-settled job.
|
||||
try { entry.worker.terminate(); } catch { /* already closed */ }
|
||||
finish(new Error(`Image crop worker exceeded the ${timeoutMs} ms timeout and was terminated`));
|
||||
}, timeoutMs);
|
||||
|
||||
function cleanup() {
|
||||
clearTimeout(timer);
|
||||
entry.worker.off('message', onMessage);
|
||||
entry.worker.off('error', onError);
|
||||
entry.worker.off('exit', onExit);
|
||||
}
|
||||
|
||||
function finish(err, value) {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
if (err) reject(err);
|
||||
else resolve(value);
|
||||
}
|
||||
|
||||
function onMessage(msg) {
|
||||
// Ignore anything that is not this job's reply. Under `node --watch` the dev
|
||||
// server's module-loader hook posts `watch:import` / `watch:require` notices on
|
||||
// the worker's parentPort for every import, so the first message is not
|
||||
// necessarily our reply. Job ids are matched instead of taking the first one.
|
||||
if (!msg || msg.id !== jobId) return;
|
||||
if (msg.ok) {
|
||||
finish(null, {
|
||||
buffer: Buffer.from(msg.buffer),
|
||||
width: msg.width,
|
||||
height: msg.height,
|
||||
size: msg.size,
|
||||
});
|
||||
} else {
|
||||
const err = new Error(msg.error || 'Image crop failed in worker');
|
||||
if (msg.errorName) err.name = msg.errorName;
|
||||
if (msg.stack) err.workerStack = msg.stack;
|
||||
finish(err);
|
||||
}
|
||||
}
|
||||
|
||||
function onError(err) {
|
||||
dropWorker(entry);
|
||||
finish(err instanceof Error ? err : new Error(err?.message || 'Image crop worker error'));
|
||||
}
|
||||
|
||||
function onExit(code) {
|
||||
dropWorker(entry);
|
||||
finish(new Error(`Image crop worker exited unexpectedly (code ${code})`));
|
||||
}
|
||||
|
||||
entry.worker.on('message', onMessage);
|
||||
entry.worker.on('error', onError);
|
||||
entry.worker.on('exit', onExit);
|
||||
|
||||
try {
|
||||
entry.worker.postMessage({ id: jobId, buffer: transfer }, [transfer]);
|
||||
} catch (err) {
|
||||
finish(err instanceof Error ? err : new Error(err?.message || 'Failed to post image to crop worker'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a dead worker from the pool. Jobs it was running have already been failed
|
||||
* by its error/exit listener; queued jobs are re-homed by drainQueue().
|
||||
* @param {{ worker: Worker }} entry
|
||||
*/
|
||||
function dropWorker(entry) {
|
||||
const index = pool.indexOf(entry);
|
||||
if (index !== -1) pool.splice(index, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts as many queued jobs as there are idle workers.
|
||||
*/
|
||||
function drainQueue() {
|
||||
while (queue.length > 0) {
|
||||
const idle = pool.find((entry) => !entry.busy);
|
||||
if (!idle) return;
|
||||
|
||||
const next = queue.shift();
|
||||
clearTimeout(next.timer);
|
||||
idle.busy = true;
|
||||
|
||||
runJob(idle, next.inputBuffer, next.timeoutMs).then(next.resolve, next.reject).finally(() => {
|
||||
idle.busy = false;
|
||||
drainQueue();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues a crop job and resolves with the worker's result.
|
||||
*
|
||||
* `drainQueue` is the single owner of dispatch: it hands each queued job an idle
|
||||
* worker and runs it, so a job can never be dispatched twice nor left waiting on a
|
||||
* worker that acquireWorker never obtained.
|
||||
*
|
||||
* @param {Buffer} inputBuffer
|
||||
* @param {number} timeoutMs
|
||||
* @returns {Promise<{ buffer: Buffer, width: number, height: number, size: number }>}
|
||||
*/
|
||||
function enqueueJob(inputBuffer, timeoutMs) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
const index = queue.findIndex((item) => item.timer === timer);
|
||||
if (index !== -1) queue.splice(index, 1);
|
||||
reject(new Error(`Image crop worker queue timed out after ${timeoutMs} ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
queue.push({ resolve, reject, timer, inputBuffer, timeoutMs });
|
||||
drainQueue();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminates every pooled worker and fails anything still queued.
|
||||
*
|
||||
* Call from shutdown so `node --watch` does not hang on "Waiting for graceful
|
||||
* termination..." the way long-lived SSE sockets do.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function shutdownImageCropWorkers() {
|
||||
shuttingDown = true;
|
||||
|
||||
while (queue.length > 0) {
|
||||
const pending = queue.shift();
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(new Error('Image crop worker pool is shutting down'));
|
||||
}
|
||||
|
||||
const entries = pool.splice(0, pool.length);
|
||||
await Promise.all(
|
||||
entries.map((entry) =>
|
||||
Promise.resolve()
|
||||
.then(() => entry.worker.terminate())
|
||||
.catch(() => { /* already closed */ })
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and normalizes an image URL.
|
||||
@@ -85,90 +375,29 @@ export async function crawlImage(url, { signal } = {}) {
|
||||
* actually trimmed, never enlarging beyond the original), resizes to max 1000px,
|
||||
* and converts to AVIF.
|
||||
*
|
||||
* Runs the sharp pipeline in a pooled worker thread (see imageCropWorker.js) so the
|
||||
* multi-pass encode never blocks the main event loop. Errors are re-thrown here with
|
||||
* the worker's message intact, so callers that classify on message text (e.g. the
|
||||
* `Failed to crawl image:` prefix check in /serper/image) keep working unchanged.
|
||||
*
|
||||
* @param {Buffer} inputBuffer - Raw image buffer
|
||||
* @param {object} [options]
|
||||
* @param {number} [options.timeout] - Hard wall-clock limit for the worker job
|
||||
* @returns {Promise<{ buffer: Buffer, width: number, height: number, size: number }>}
|
||||
*/
|
||||
export 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
|
||||
}
|
||||
export async function cropAndConvertToAvif(inputBuffer, { timeout = DEFAULT_JOB_TIMEOUT_MS } = {}) {
|
||||
if (!Buffer.isBuffer(inputBuffer)) {
|
||||
throw new TypeError('cropAndConvertToAvif expects a Buffer');
|
||||
}
|
||||
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,
|
||||
});
|
||||
if (shuttingDown) {
|
||||
throw new Error('Image crop worker pool is shutting down');
|
||||
}
|
||||
|
||||
// 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,
|
||||
};
|
||||
// Grow the pool on demand, then queue. enqueueJob/drainQueue hand the job to the
|
||||
// first idle worker, so an idle worker is never leased twice and a saturated pool
|
||||
// simply waits instead of piling up sharp pipelines.
|
||||
await acquireWorker();
|
||||
return enqueueJob(inputBuffer, timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -177,9 +406,10 @@ export async function cropAndConvertToAvif(inputBuffer) {
|
||||
* @param {string} url - Image URL to crawl
|
||||
* @param {object} [options]
|
||||
* @param {AbortSignal} [options.signal] - Abort signal
|
||||
* @param {number} [options.timeout] - Hard wall-clock limit for the crop worker
|
||||
* @returns {Promise<{ buffer: Buffer, width: number, height: number, size: number }>}
|
||||
*/
|
||||
export async function crawlAndProcessImage(url, { signal } = {}) {
|
||||
export async function crawlAndProcessImage(url, { signal, timeout } = {}) {
|
||||
const rawBuffer = await crawlImage(url, { signal });
|
||||
return cropAndConvertToAvif(rawBuffer);
|
||||
return cropAndConvertToAvif(rawBuffer, { timeout });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user