diff --git a/docs/api/routes/vfs/exec.md b/docs/api/routes/vfs/exec.md
index 63a79fb..c6bd27f 100644
--- a/docs/api/routes/vfs/exec.md
+++ b/docs/api/routes/vfs/exec.md
@@ -20,7 +20,7 @@ Applets return typed CSV, not JSON: `result` is a `;`-separated CSV string with
response also carries `columns` (name/type) and `rows` (data row count). Only declared columns are
emitted, in header order; missing values become empty cells. Execution runs in the same isolated worker
sandbox as the `javascript_execute` tool — including authenticated `fetch()` against this API,
-`fs`/`vfs`, `sharp`, ExcelJS, PDFKit and WebCrypto globals.
+`fs`/`vfs`, `optimizeImage`, ExcelJS, PDFKit and WebCrypto globals.
The path is taken from the URL (like `DELETE /vfs/{*path}`); the run arguments are the JSON body.
Path traversal (`..`), null bytes, and backslashes are rejected with HTTP 400, as is a path that does
diff --git a/package-lock.json b/package-lock.json
index a6a33c6..59eb48c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -31,10 +31,10 @@
"react": "^19.3.1",
"react-canvas-label-editor": "github:sebseb7/react-canvas-label-editor",
"selfsigned": "^5.5.0",
- "sharp": "^0.35.5",
"skelehash-wasm": "^0.1.1",
"swagger-ui-dist": "^5.33.3",
"vm2": "^3.12.2",
+ "wasm-image-optimization": "^2.0.13",
"zod": "^4.6.7"
},
"devDependencies": {
@@ -53,9 +53,9 @@
"chalk": "^6.0.1",
"dotenv": "^18.0.8",
"pdfconvert-wasm": "^0.3.0",
- "sharp": "^0.35.5",
"skelehash-wasm": "^0.1.1",
- "thumbhash": "^0.1.1"
+ "thumbhash": "^0.1.1",
+ "wasm-image-optimization": "^2.0.13"
},
"devDependencies": {
"better-sqlite3": "^13.0.3",
@@ -72,7 +72,6 @@
}
},
"../llmorbiter": {
- "name": "llmorbiter",
"version": "0.1.0",
"license": "0BSD",
"dependencies": {
@@ -6436,6 +6435,28 @@
"node": ">=6.0"
}
},
+ "node_modules/wasm-image-optimization": {
+ "version": "2.0.13",
+ "resolved": "https://registry.npmjs.org/wasm-image-optimization/-/wasm-image-optimization-2.0.13.tgz",
+ "integrity": "sha512-MGaFZtdxMHZyaL3VUUcNkN8qccJcy33lcXoUIaegav/y1TiK6c2aALbKAjoe4bXHFI7nDl+5+TG0e9Y8j1/h9Q==",
+ "license": "MIT",
+ "dependencies": {
+ "commander": "15.0.0",
+ "worker-lib": "2.2.1"
+ },
+ "bin": {
+ "wasm-image-optimization": "dist/cli.js"
+ }
+ },
+ "node_modules/wasm-image-optimization/node_modules/commander": {
+ "version": "15.0.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz",
+ "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
@@ -6566,6 +6587,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/worker-lib": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/worker-lib/-/worker-lib-2.2.1.tgz",
+ "integrity": "sha512-CauU4vz3vZUBG3A2CY93GY94KLhbH8JtPKtuvKu2w5G07cHkZ73QkTAJ/lEHDL6arT5WDu1qpU++T0+kGDgSCg==",
+ "license": "MIT"
+ },
"node_modules/wrap-ansi": {
"version": "10.0.2",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.2.tgz",
diff --git a/package.json b/package.json
index 66d8b0e..9197974 100644
--- a/package.json
+++ b/package.json
@@ -41,10 +41,10 @@
"react": "^19.3.1",
"react-canvas-label-editor": "github:sebseb7/react-canvas-label-editor",
"selfsigned": "^5.5.0",
- "sharp": "^0.35.5",
"skelehash-wasm": "^0.1.1",
"swagger-ui-dist": "^5.33.3",
"vm2": "^3.12.2",
+ "wasm-image-optimization": "^2.0.13",
"zod": "^4.6.7"
},
"devDependencies": {
diff --git a/scripts/testImageCrop.js b/scripts/testImageCrop.js
index d697200..a5c2a0b 100644
--- a/scripts/testImageCrop.js
+++ b/scripts/testImageCrop.js
@@ -1,73 +1,46 @@
#!/usr/bin/env node
-import sharp from 'sharp';
+import { optimizeImage } from 'wasm-image-optimization';
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(''),
- top: 100,
- left: 100,
- },
- ])
- .png()
- .toBuffer();
+ const whiteSvg = ``;
+ const whiteImg = Buffer.from((await optimizeImage({ image: whiteSvg, format: 'png' })).data);
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}`);
+ if (res1.width !== 110 || res1.height !== 110) {
+ throw new Error(`Expected 110x110, 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(''),
- top: 110,
- left: 90,
- },
- ])
- .png()
- .toBuffer();
+ const transSvg = ``;
+ const transImg = Buffer.from((await optimizeImage({ image: transSvg, format: 'png' })).data);
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}`);
+ if (res2.width !== 130 || res2.height !== 90) {
+ throw new Error(`Expected 130x90, 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(''),
- top: 100,
- left: 100,
- },
- {
- input: Buffer.from(''),
- top: 170,
- left: 150,
- },
- ])
- .png()
- .toBuffer();
+ const mixedSvg = ``;
+ const mixedImg = Buffer.from((await optimizeImage({ image: mixedSvg, format: 'png' })).data);
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}`);
+ if (res3.width !== 110 || res3.height !== 70) {
+ throw new Error(`Expected 110x70, got ${res3.width}x${res3.height}`);
}
console.log('\nAll synthetic tests PASSED!');
diff --git a/src/api/routes/serper.js b/src/api/routes/serper.js
index a4de4b2..2bdcce6 100644
--- a/src/api/routes/serper.js
+++ b/src/api/routes/serper.js
@@ -1,4 +1,4 @@
-import sharp from 'sharp';
+import { optimizeImage } from 'wasm-image-optimization';
import * as dbCompanySetting from '../../db/companysetting.js';
import { crawlAndProcessImage } from '../../services/imageCrop.js';
@@ -60,22 +60,29 @@ async function generateWebpThumbnail(img, { signal, preferSource = false } = {})
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
- const thumb = await sharp(buffer)
- .rotate()
- .resize(200, 200, {
- fit: 'inside',
- withoutEnlargement: true,
- })
- .webp({ quality: 80 })
- .toBuffer({ resolveWithObject: true });
+ const meta = await optimizeImage({ image: buffer, format: 'none' });
+ const srcW = meta.originalWidth || 0;
+ const srcH = meta.originalHeight || 0;
+ const needsResize = srcW > 200 || srcH > 200;
+ const scale = needsResize ? Math.min(200 / srcW, 200 / srcH) : 1;
- const dataUrl = `data:image/webp;base64,${thumb.data.toString('base64')}`;
+ const thumb = await optimizeImage({
+ image: buffer,
+ width: needsResize ? Math.max(1, Math.round(srcW * scale)) : undefined,
+ height: needsResize ? Math.max(1, Math.round(srcH * scale)) : undefined,
+ fit: 'fill',
+ format: 'webp',
+ quality: 80,
+ });
+
+ const thumbBuffer = Buffer.from(thumb.data);
+ const dataUrl = `data:image/webp;base64,${thumbBuffer.toString('base64')}`;
return {
dataUrl,
- width: thumb.info.width,
- height: thumb.info.height,
- size: thumb.info.size,
+ width: thumb.width,
+ height: thumb.height,
+ size: thumbBuffer.length,
};
} catch (err) {
lastError = err;
diff --git a/src/api/routes/vfs.js b/src/api/routes/vfs.js
index 38594e7..1131c48 100644
--- a/src/api/routes/vfs.js
+++ b/src/api/routes/vfs.js
@@ -10,7 +10,7 @@ import {
restoreDefaultSkills,
restoreDefaultApplets,
} from '../../tools/vfs.js';
-import sharp from 'sharp';
+import { optimizeImage } from 'wasm-image-optimization';
// #region Helpers
@@ -286,74 +286,79 @@ function buildMetaOverlay({ width, height, size, mime, filename }, outWidth, out
* @returns {Promise<{ buffer: Buffer, contentType: string } | null>} null when not rasterisable
*/
async function renderThumbnail(buffer, { mime, filename }) {
- const pipeline = sharp(buffer, { animated: false, failOn: 'none' });
let meta;
try {
- meta = await pipeline.metadata();
+ meta = await optimizeImage({ image: buffer, format: 'none' });
} catch {
return null;
}
- if (!meta?.width || !meta?.height) return null;
+ const origW = meta?.originalWidth;
+ const origH = meta?.originalHeight;
+ if (!origW || !origH) return null;
- let scaled = await pipeline
- .resize({
- width: THUMB_BOX,
- height: THUMB_BOX,
- fit: 'inside',
- // Downscale only. Upscaling is handled explicitly below, so a 60x40 image ends up at
- // 100px (the THUMB_MIN_EDGE rule) rather than being blown up to fill the 250 box.
- withoutEnlargement: true,
- })
- .png()
- .toBuffer();
+ // Downscale to fit inside THUMB_BOX without enlargement, then explicitly upscale
+ // anything whose larger edge is still under THUMB_MIN_EDGE.
+ const downScale = Math.min(1, THUMB_BOX / origW, THUMB_BOX / origH);
+ let outWidth = Math.max(1, Math.round(origW * downScale));
+ let outHeight = Math.max(1, Math.round(origH * downScale));
- let scaledMeta = await sharp(scaled).metadata();
-
- // Upscale small images so they do not render as an unreadable speck.
- const largerEdge = Math.max(scaledMeta.width, scaledMeta.height);
+ const largerEdge = Math.max(outWidth, outHeight);
if (largerEdge < THUMB_MIN_EDGE) {
const factor = THUMB_MIN_EDGE / largerEdge;
- scaled = await sharp(scaled)
- .resize({
- width: Math.max(1, Math.round(scaledMeta.width * factor)),
- height: Math.max(1, Math.round(scaledMeta.height * factor)),
- fit: 'fill',
- })
- .png()
- .toBuffer();
- scaledMeta = await sharp(scaled).metadata();
+ outWidth = Math.max(1, Math.round(outWidth * factor));
+ outHeight = Math.max(1, Math.round(outHeight * factor));
}
- const outWidth = scaledMeta.width;
- const outHeight = scaledMeta.height;
-
const overlay = buildMetaOverlay(
- {
- width: meta.width,
- height: meta.height,
- size: buffer.length,
- mime,
- filename,
- },
- outWidth,
+ {
+ width: origW,
+ height: origH,
+ size: buffer.length,
+ mime,
+ filename,
+ },
+ outWidth,
outHeight
);
// `null` means the thumbnail is too small to carry a legible metadata band (e.g. an extreme
// aspect ratio collapsing to a few pixels tall). Serve the plain scaled image instead.
if (!overlay) {
- return {
- buffer: await sharp(scaled)[THUMB_FORMAT]({ quality: THUMB_QUALITY }).toBuffer(),
- contentType: `image/${THUMB_FORMAT}`,
- };
+ const plain = await optimizeImage({
+ image: buffer,
+ width: outWidth,
+ height: outHeight,
+ fit: 'fill',
+ format: THUMB_FORMAT,
+ quality: THUMB_QUALITY,
+ });
+ return {
+ buffer: Buffer.from(plain.data),
+ contentType: `image/${THUMB_FORMAT}`,
+ };
}
- const composite = await sharp(scaled)
- .composite([{ input: overlay, top: 0, left: 0 }])
- [THUMB_FORMAT]({ quality: THUMB_QUALITY })
- .toBuffer();
+
+ const scaledPng = await optimizeImage({
+ image: buffer,
+ width: outWidth,
+ height: outHeight,
+ fit: 'fill',
+ format: 'png',
+ });
+ const b64 = Buffer.from(scaledPng.data).toString('base64');
+ const overlayStr = overlay.toString('utf8');
+ const compositeSvg = overlayStr.replace(
+ /(