u
This commit is contained in:
@@ -132,6 +132,7 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
|
|||||||
| [`routes/quote/delete.md`](routes/quote/delete.md) | `DELETE /quote/{id}` |
|
| [`routes/quote/delete.md`](routes/quote/delete.md) | `DELETE /quote/{id}` |
|
||||||
| [`routes/quote/get.md`](routes/quote/get.md) | `GET /quote/{id}` |
|
| [`routes/quote/get.md`](routes/quote/get.md) | `GET /quote/{id}` |
|
||||||
| [`routes/quote/update.md`](routes/quote/update.md) | `PATCH /quote/{id}` |
|
| [`routes/quote/update.md`](routes/quote/update.md) | `PATCH /quote/{id}` |
|
||||||
|
| [`routes/serper/image.md`](routes/serper/image.md) | `POST /serper/image` |
|
||||||
| [`routes/serper/images.md`](routes/serper/images.md) | `POST /serper/images` |
|
| [`routes/serper/images.md`](routes/serper/images.md) | `POST /serper/images` |
|
||||||
| [`routes/stock/adjust.md`](routes/stock/adjust.md) | `POST /stock/adjust` |
|
| [`routes/stock/adjust.md`](routes/stock/adjust.md) | `POST /stock/adjust` |
|
||||||
| [`routes/supplier/list.md`](routes/supplier/list.md) | `GET /supplier` |
|
| [`routes/supplier/list.md`](routes/supplier/list.md) | `GET /supplier` |
|
||||||
|
|||||||
@@ -1126,6 +1126,114 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/serper/image": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Serper"
|
||||||
|
],
|
||||||
|
"summary": "Crawl image from URL, crop white/transparency borders, and return AVIF",
|
||||||
|
"description": "Fetches the image at the provided `url`, crops surrounding white and\ntransparent border margins, resizes to max 1000px, and returns the AVIF image.\n",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"cookieAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"x-markdown": "serper/image.md",
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"url"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"url": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Absolute HTTP/HTTPS URL of the image to crawl",
|
||||||
|
"example": "https://example.com/logo.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "AVIF image bytes",
|
||||||
|
"headers": {
|
||||||
|
"Content-Type": {
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "image/avif"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"X-Image-Width": {
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 540
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"X-Image-Height": {
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 179
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"image/avif": {
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "binary"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "URL missing, invalid input, or image processing error",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Not authorized",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"502": {
|
||||||
|
"description": "Bad Gateway (crawling the upstream image URL failed)",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/quote": {
|
"/quote": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
34
docs/api/routes/serper/image.md
Normal file
34
docs/api/routes/serper/image.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# `POST /serper/image`
|
||||||
|
|
||||||
|
Protected. Requires a valid session cookie (`credentials: 'include'`).
|
||||||
|
|
||||||
|
Crawl image from URL, crop white/transparency borders, and return AVIF
|
||||||
|
|
||||||
|
Fetches the image at the provided `url`, crops surrounding white and
|
||||||
|
transparent border margins, resizes to max 1000px, and returns the AVIF image.
|
||||||
|
|
||||||
|
|
||||||
|
## Body (JSON)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"url": "https://example.com/logo.png"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Response `200`
|
||||||
|
|
||||||
|
AVIF image bytes
|
||||||
|
|
||||||
|
## Errors
|
||||||
|
|
||||||
|
| Status | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| 400 | URL missing, invalid input, or image processing error — `{"error":"…"}` |
|
||||||
|
| 401 | Unauthorized — `{"error":"…"}` |
|
||||||
|
| 403 | Not authorized — `{"error":"…"}` |
|
||||||
|
| 502 | Bad Gateway (crawling the upstream image URL failed) — `{"error":"…"}` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
|
||||||
89
scripts/testImageCrop.js
Normal file
89
scripts/testImageCrop.js
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import { cropAndConvertToAvif, crawlAndProcessImage } from '../src/services/imageCrop.js';
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
console.log('--- 1. Testing synthetic images ---');
|
||||||
|
|
||||||
|
// Synthetic test 1: White border
|
||||||
|
const whiteImg = await sharp({
|
||||||
|
create: { width: 300, height: 300, channels: 4, background: { r: 255, g: 255, b: 255, alpha: 1 } },
|
||||||
|
})
|
||||||
|
.composite([
|
||||||
|
{
|
||||||
|
input: Buffer.from('<svg width="100" height="100"><rect width="100" height="100" fill="purple"/></svg>'),
|
||||||
|
top: 100,
|
||||||
|
left: 100,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
const res1 = await cropAndConvertToAvif(whiteImg);
|
||||||
|
console.log(`White border: 300x300 -> ${res1.width}x${res1.height}, ${res1.size} bytes`);
|
||||||
|
if (res1.width !== 100 || res1.height !== 100) {
|
||||||
|
throw new Error(`Expected 100x100, got ${res1.width}x${res1.height}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Synthetic test 2: Transparent border
|
||||||
|
const transImg = await sharp({
|
||||||
|
create: { width: 300, height: 300, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } },
|
||||||
|
})
|
||||||
|
.composite([
|
||||||
|
{
|
||||||
|
input: Buffer.from('<svg width="120" height="80"><rect width="120" height="80" fill="green"/></svg>'),
|
||||||
|
top: 110,
|
||||||
|
left: 90,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
const res2 = await cropAndConvertToAvif(transImg);
|
||||||
|
console.log(`Transparent border: 300x300 -> ${res2.width}x${res2.height}, ${res2.size} bytes`);
|
||||||
|
if (res2.width !== 120 || res2.height !== 80) {
|
||||||
|
throw new Error(`Expected 120x80, got ${res2.width}x${res2.height}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Synthetic test 3: Outer transparent, inner white border
|
||||||
|
const mixedImg = await sharp({
|
||||||
|
create: { width: 400, height: 400, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } },
|
||||||
|
})
|
||||||
|
.composite([
|
||||||
|
{
|
||||||
|
input: Buffer.from('<svg width="200" height="200"><rect width="200" height="200" fill="white"/></svg>'),
|
||||||
|
top: 100,
|
||||||
|
left: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: Buffer.from('<svg width="100" height="60"><rect width="100" height="60" fill="blue"/></svg>'),
|
||||||
|
top: 170,
|
||||||
|
left: 150,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
const res3 = await cropAndConvertToAvif(mixedImg);
|
||||||
|
console.log(`Mixed border: 400x400 -> ${res3.width}x${res3.height}, ${res3.size} bytes`);
|
||||||
|
if (res3.width !== 100 || res3.height !== 60) {
|
||||||
|
throw new Error(`Expected 100x60, got ${res3.width}x${res3.height}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nAll synthetic tests PASSED!');
|
||||||
|
|
||||||
|
// CLI URL test if provided as argument
|
||||||
|
const testUrl = process.argv[2];
|
||||||
|
if (testUrl) {
|
||||||
|
console.log(`\n--- 2. Crawling URL: ${testUrl} ---`);
|
||||||
|
const urlRes = await crawlAndProcessImage(testUrl);
|
||||||
|
console.log(`Crawled & processed: ${urlRes.width}x${urlRes.height}, ${urlRes.size} bytes`);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch((err) => {
|
||||||
|
console.error('Test failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
import * as dbCompanySetting from '../../db/companysetting.js';
|
import * as dbCompanySetting from '../../db/companysetting.js';
|
||||||
import env from '../../config/env.js';
|
import env from '../../config/env.js';
|
||||||
|
import { crawlAndProcessImage } from '../../services/imageCrop.js';
|
||||||
|
|
||||||
const CONCURRENCY = 6;
|
const CONCURRENCY = 6;
|
||||||
const FETCH_TIMEOUT_MS = 6000;
|
const FETCH_TIMEOUT_MS = 6000;
|
||||||
@@ -342,6 +343,113 @@ export function register(app, basePath = '') {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /serper/image:
|
||||||
|
* post:
|
||||||
|
* tags: [Serper]
|
||||||
|
* summary: Crawl image from URL, crop white/transparency borders, and return AVIF
|
||||||
|
* description: |
|
||||||
|
* Fetches the image at the provided `url`, crops surrounding white and
|
||||||
|
* transparent border margins, resizes to max 1000px, and returns the AVIF image.
|
||||||
|
* security:
|
||||||
|
* - cookieAuth: []
|
||||||
|
* x-markdown: serper/image.md
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* required: [url]
|
||||||
|
* properties:
|
||||||
|
* url:
|
||||||
|
* type: string
|
||||||
|
* description: Absolute HTTP/HTTPS URL of the image to crawl
|
||||||
|
* example: "https://example.com/logo.png"
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: AVIF image bytes
|
||||||
|
* headers:
|
||||||
|
* Content-Type:
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* example: image/avif
|
||||||
|
* X-Image-Width:
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* example: 540
|
||||||
|
* X-Image-Height:
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* example: 179
|
||||||
|
* content:
|
||||||
|
* image/avif:
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* format: binary
|
||||||
|
* 400:
|
||||||
|
* description: URL missing, invalid input, or image processing error
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/Error'
|
||||||
|
* 401:
|
||||||
|
* description: Unauthorized
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/Error'
|
||||||
|
* 403:
|
||||||
|
* description: Not authorized
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/Error'
|
||||||
|
* 502:
|
||||||
|
* description: Bad Gateway (crawling the upstream image URL failed)
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/Error'
|
||||||
|
*/
|
||||||
|
const handleImageCrop = async (req, res) => {
|
||||||
|
const selected = requireSelectedCompany(req);
|
||||||
|
if (selected.error) {
|
||||||
|
return res.status(400).json({ error: selected.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawUrl = typeof req.body?.url === 'string' ? req.body.url.trim() : '';
|
||||||
|
if (!rawUrl) {
|
||||||
|
return res.status(400).json({ error: 'url is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const onClose = () => abortController.abort();
|
||||||
|
req.on('close', onClose);
|
||||||
|
res.on('close', onClose);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await crawlAndProcessImage(rawUrl, {
|
||||||
|
signal: abortController.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.writableEnded || res.destroyed) return;
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'image/avif');
|
||||||
|
res.setHeader('Content-Length', String(result.buffer.length));
|
||||||
|
res.setHeader('X-Image-Width', String(result.width));
|
||||||
|
res.setHeader('X-Image-Height', String(result.height));
|
||||||
|
return res.status(200).send(result.buffer);
|
||||||
|
} catch (err) {
|
||||||
|
if (abortController.signal.aborted || res.writableEnded || res.destroyed) return;
|
||||||
|
const message = err?.message || 'Failed to process image';
|
||||||
|
const isUpstream = message.startsWith('Failed to crawl image:');
|
||||||
|
const status = isUpstream ? 502 : 400;
|
||||||
|
return res.status(status).json({ error: message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
app.post(`${basePath}/serper/images`, handleImagesSearch);
|
app.post(`${basePath}/serper/images`, handleImagesSearch);
|
||||||
app.post(`${basePath}/search/images`, handleImagesSearch);
|
app.post(`${basePath}/serper/image`, handleImageCrop);
|
||||||
}
|
}
|
||||||
|
|||||||
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