u
This commit is contained in:
175
src/services/imageCrop.js
Normal file
175
src/services/imageCrop.js
Normal file
@@ -0,0 +1,175 @@
|
||||
import sharp from 'sharp';
|
||||
|
||||
const FETCH_TIMEOUT_MS = 15000;
|
||||
const MAX_BYTES = 50 * 1024 * 1024; // 50MB
|
||||
const MAX_EDGE = 1000;
|
||||
const TRIM_THRESHOLD = 10;
|
||||
const AVIF_QUALITY = 80;
|
||||
const AVIF_EFFORT = 4;
|
||||
|
||||
/**
|
||||
* Validates and normalizes an image URL.
|
||||
* @param {string} url
|
||||
* @returns {URL}
|
||||
*/
|
||||
export function validateImageUrl(url) {
|
||||
if (typeof url !== 'string' || !url.trim()) {
|
||||
throw new Error('url is required');
|
||||
}
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(url.trim());
|
||||
} catch {
|
||||
throw new Error('Invalid URL');
|
||||
}
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
throw new Error(`Unsupported URL protocol: ${parsed.protocol}. Only http: and https: are allowed.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crawls/downloads an image from a URL.
|
||||
*
|
||||
* @param {string} url
|
||||
* @param {object} [options]
|
||||
* @param {AbortSignal} [options.signal]
|
||||
* @returns {Promise<Buffer>}
|
||||
*/
|
||||
export async function crawlImage(url, { signal } = {}) {
|
||||
const parsedUrl = validateImageUrl(url);
|
||||
|
||||
const fetchSignal = signal
|
||||
? AbortSignal.any([signal, AbortSignal.timeout(FETCH_TIMEOUT_MS)])
|
||||
: AbortSignal.timeout(FETCH_TIMEOUT_MS);
|
||||
|
||||
const response = await fetch(parsedUrl.href, {
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.9,de;q=0.8',
|
||||
'Referer': parsedUrl.origin + '/',
|
||||
},
|
||||
signal: fetchSignal,
|
||||
redirect: 'follow',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to crawl image: HTTP ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const contentLength = Number(response.headers.get('content-length'));
|
||||
if (Number.isFinite(contentLength) && contentLength > MAX_BYTES) {
|
||||
throw new Error(`Image size exceeds limit of ${MAX_BYTES} bytes`);
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
if (buffer.length === 0) {
|
||||
throw new Error('Crawled image is empty (0 bytes)');
|
||||
}
|
||||
|
||||
if (buffer.length > MAX_BYTES) {
|
||||
throw new Error(`Image size (${buffer.length} bytes) exceeds limit of ${MAX_BYTES} bytes`);
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trims transparent and white borders from an image buffer, resizes to max 1000px, and converts to AVIF.
|
||||
*
|
||||
* @param {Buffer} inputBuffer - Raw image buffer
|
||||
* @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();
|
||||
|
||||
// Use PNG intermediate for trimming passes to preserve alpha
|
||||
let currentBuffer = await baseSharp.png().toBuffer();
|
||||
let currentMeta = initialMeta;
|
||||
|
||||
// 2. Step A: Trim transparent borders if alpha is present
|
||||
if (currentMeta.hasAlpha) {
|
||||
try {
|
||||
const trimmed = await sharp(currentBuffer)
|
||||
.trim({ background: { r: 0, g: 0, b: 0, alpha: 0 }, threshold: TRIM_THRESHOLD })
|
||||
.png()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
if (trimmed.info.width > 0 && trimmed.info.height > 0) {
|
||||
currentBuffer = trimmed.data;
|
||||
currentMeta = trimmed.info;
|
||||
}
|
||||
} catch {
|
||||
// Keep currentBuffer
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Step B: Trim white borders
|
||||
try {
|
||||
const trimmed = await sharp(currentBuffer)
|
||||
.trim({ background: '#ffffff', threshold: TRIM_THRESHOLD })
|
||||
.png()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
if (trimmed.info.width > 0 && trimmed.info.height > 0) {
|
||||
currentBuffer = trimmed.data;
|
||||
currentMeta = trimmed.info;
|
||||
}
|
||||
} catch {
|
||||
// Keep currentBuffer
|
||||
}
|
||||
|
||||
// 4. Step C: Trim transparent borders again (handles white border containing transparent padding)
|
||||
if (currentMeta.hasAlpha) {
|
||||
try {
|
||||
const trimmed = await sharp(currentBuffer)
|
||||
.trim({ background: { r: 0, g: 0, b: 0, alpha: 0 }, threshold: TRIM_THRESHOLD })
|
||||
.png()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
if (trimmed.info.width > 0 && trimmed.info.height > 0) {
|
||||
currentBuffer = trimmed.data;
|
||||
currentMeta = trimmed.info;
|
||||
}
|
||||
} catch {
|
||||
// Keep currentBuffer
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Crawls a URL, crops white & transparency borders, resizes to max 1000px, and returns AVIF.
|
||||
*
|
||||
* @param {string} url - Image URL to crawl
|
||||
* @param {object} [options]
|
||||
* @param {AbortSignal} [options.signal] - Abort signal
|
||||
* @returns {Promise<{ buffer: Buffer, width: number, height: number, size: number }>}
|
||||
*/
|
||||
export async function crawlAndProcessImage(url, { signal } = {}) {
|
||||
const rawBuffer = await crawlImage(url, { signal });
|
||||
return cropAndConvertToAvif(rawBuffer);
|
||||
}
|
||||
Reference in New Issue
Block a user