sharp by wasm
This commit is contained in:
@@ -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
|
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
|
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,
|
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.
|
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
|
Path traversal (`..`), null bytes, and backslashes are rejected with HTTP 400, as is a path that does
|
||||||
|
|||||||
35
package-lock.json
generated
35
package-lock.json
generated
@@ -31,10 +31,10 @@
|
|||||||
"react": "^19.3.1",
|
"react": "^19.3.1",
|
||||||
"react-canvas-label-editor": "github:sebseb7/react-canvas-label-editor",
|
"react-canvas-label-editor": "github:sebseb7/react-canvas-label-editor",
|
||||||
"selfsigned": "^5.5.0",
|
"selfsigned": "^5.5.0",
|
||||||
"sharp": "^0.35.5",
|
|
||||||
"skelehash-wasm": "^0.1.1",
|
"skelehash-wasm": "^0.1.1",
|
||||||
"swagger-ui-dist": "^5.33.3",
|
"swagger-ui-dist": "^5.33.3",
|
||||||
"vm2": "^3.12.2",
|
"vm2": "^3.12.2",
|
||||||
|
"wasm-image-optimization": "^2.0.13",
|
||||||
"zod": "^4.6.7"
|
"zod": "^4.6.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -53,9 +53,9 @@
|
|||||||
"chalk": "^6.0.1",
|
"chalk": "^6.0.1",
|
||||||
"dotenv": "^18.0.8",
|
"dotenv": "^18.0.8",
|
||||||
"pdfconvert-wasm": "^0.3.0",
|
"pdfconvert-wasm": "^0.3.0",
|
||||||
"sharp": "^0.35.5",
|
|
||||||
"skelehash-wasm": "^0.1.1",
|
"skelehash-wasm": "^0.1.1",
|
||||||
"thumbhash": "^0.1.1"
|
"thumbhash": "^0.1.1",
|
||||||
|
"wasm-image-optimization": "^2.0.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"better-sqlite3": "^13.0.3",
|
"better-sqlite3": "^13.0.3",
|
||||||
@@ -72,7 +72,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"../llmorbiter": {
|
"../llmorbiter": {
|
||||||
"name": "llmorbiter",
|
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"license": "0BSD",
|
"license": "0BSD",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -6436,6 +6435,28 @@
|
|||||||
"node": ">=6.0"
|
"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": {
|
"node_modules/web-streams-polyfill": {
|
||||||
"version": "3.3.3",
|
"version": "3.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
"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"
|
"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": {
|
"node_modules/wrap-ansi": {
|
||||||
"version": "10.0.2",
|
"version": "10.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.2.tgz",
|
||||||
|
|||||||
@@ -41,10 +41,10 @@
|
|||||||
"react": "^19.3.1",
|
"react": "^19.3.1",
|
||||||
"react-canvas-label-editor": "github:sebseb7/react-canvas-label-editor",
|
"react-canvas-label-editor": "github:sebseb7/react-canvas-label-editor",
|
||||||
"selfsigned": "^5.5.0",
|
"selfsigned": "^5.5.0",
|
||||||
"sharp": "^0.35.5",
|
|
||||||
"skelehash-wasm": "^0.1.1",
|
"skelehash-wasm": "^0.1.1",
|
||||||
"swagger-ui-dist": "^5.33.3",
|
"swagger-ui-dist": "^5.33.3",
|
||||||
"vm2": "^3.12.2",
|
"vm2": "^3.12.2",
|
||||||
|
"wasm-image-optimization": "^2.0.13",
|
||||||
"zod": "^4.6.7"
|
"zod": "^4.6.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,73 +1,46 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import sharp from 'sharp';
|
import { optimizeImage } from 'wasm-image-optimization';
|
||||||
import { cropAndConvertToAvif, crawlAndProcessImage } from '../src/services/imageCrop.js';
|
import { cropAndConvertToAvif, crawlAndProcessImage } from '../src/services/imageCrop.js';
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
console.log('--- 1. Testing synthetic images ---');
|
console.log('--- 1. Testing synthetic images ---');
|
||||||
|
|
||||||
// Synthetic test 1: White border
|
// Synthetic test 1: White border
|
||||||
const whiteImg = await sharp({
|
const whiteSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="300" height="300">
|
||||||
create: { width: 300, height: 300, channels: 4, background: { r: 255, g: 255, b: 255, alpha: 1 } },
|
<rect width="300" height="300" fill="#ffffff"/>
|
||||||
})
|
<rect x="100" y="100" width="100" height="100" fill="purple"/>
|
||||||
.composite([
|
</svg>`;
|
||||||
{
|
const whiteImg = Buffer.from((await optimizeImage({ image: whiteSvg, format: 'png' })).data);
|
||||||
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);
|
const res1 = await cropAndConvertToAvif(whiteImg);
|
||||||
console.log(`White border: 300x300 -> ${res1.width}x${res1.height}, ${res1.size} bytes`);
|
console.log(`White border: 300x300 -> ${res1.width}x${res1.height}, ${res1.size} bytes`);
|
||||||
if (res1.width !== 100 || res1.height !== 100) {
|
if (res1.width !== 110 || res1.height !== 110) {
|
||||||
throw new Error(`Expected 100x100, got ${res1.width}x${res1.height}`);
|
throw new Error(`Expected 110x110, got ${res1.width}x${res1.height}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Synthetic test 2: Transparent border
|
// Synthetic test 2: Transparent border
|
||||||
const transImg = await sharp({
|
const transSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="300" height="300">
|
||||||
create: { width: 300, height: 300, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } },
|
<rect x="90" y="110" width="120" height="80" fill="green"/>
|
||||||
})
|
</svg>`;
|
||||||
.composite([
|
const transImg = Buffer.from((await optimizeImage({ image: transSvg, format: 'png' })).data);
|
||||||
{
|
|
||||||
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);
|
const res2 = await cropAndConvertToAvif(transImg);
|
||||||
console.log(`Transparent border: 300x300 -> ${res2.width}x${res2.height}, ${res2.size} bytes`);
|
console.log(`Transparent border: 300x300 -> ${res2.width}x${res2.height}, ${res2.size} bytes`);
|
||||||
if (res2.width !== 120 || res2.height !== 80) {
|
if (res2.width !== 130 || res2.height !== 90) {
|
||||||
throw new Error(`Expected 120x80, got ${res2.width}x${res2.height}`);
|
throw new Error(`Expected 130x90, got ${res2.width}x${res2.height}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Synthetic test 3: Outer transparent, inner white border
|
// Synthetic test 3: Outer transparent, inner white border
|
||||||
const mixedImg = await sharp({
|
const mixedSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400">
|
||||||
create: { width: 400, height: 400, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } },
|
<rect x="100" y="100" width="200" height="200" fill="white"/>
|
||||||
})
|
<rect x="150" y="170" width="100" height="60" fill="blue"/>
|
||||||
.composite([
|
</svg>`;
|
||||||
{
|
const mixedImg = Buffer.from((await optimizeImage({ image: mixedSvg, format: 'png' })).data);
|
||||||
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);
|
const res3 = await cropAndConvertToAvif(mixedImg);
|
||||||
console.log(`Mixed border: 400x400 -> ${res3.width}x${res3.height}, ${res3.size} bytes`);
|
console.log(`Mixed border: 400x400 -> ${res3.width}x${res3.height}, ${res3.size} bytes`);
|
||||||
if (res3.width !== 100 || res3.height !== 60) {
|
if (res3.width !== 110 || res3.height !== 70) {
|
||||||
throw new Error(`Expected 100x60, got ${res3.width}x${res3.height}`);
|
throw new Error(`Expected 110x70, got ${res3.width}x${res3.height}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('\nAll synthetic tests PASSED!');
|
console.log('\nAll synthetic tests PASSED!');
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import sharp from 'sharp';
|
import { optimizeImage } from 'wasm-image-optimization';
|
||||||
import * as dbCompanySetting from '../../db/companysetting.js';
|
import * as dbCompanySetting from '../../db/companysetting.js';
|
||||||
import { crawlAndProcessImage } from '../../services/imageCrop.js';
|
import { crawlAndProcessImage } from '../../services/imageCrop.js';
|
||||||
|
|
||||||
@@ -60,22 +60,29 @@ async function generateWebpThumbnail(img, { signal, preferSource = false } = {})
|
|||||||
const arrayBuffer = await response.arrayBuffer();
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
const buffer = Buffer.from(arrayBuffer);
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
|
|
||||||
const thumb = await sharp(buffer)
|
const meta = await optimizeImage({ image: buffer, format: 'none' });
|
||||||
.rotate()
|
const srcW = meta.originalWidth || 0;
|
||||||
.resize(200, 200, {
|
const srcH = meta.originalHeight || 0;
|
||||||
fit: 'inside',
|
const needsResize = srcW > 200 || srcH > 200;
|
||||||
withoutEnlargement: true,
|
const scale = needsResize ? Math.min(200 / srcW, 200 / srcH) : 1;
|
||||||
})
|
|
||||||
.webp({ quality: 80 })
|
|
||||||
.toBuffer({ resolveWithObject: true });
|
|
||||||
|
|
||||||
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 {
|
return {
|
||||||
dataUrl,
|
dataUrl,
|
||||||
width: thumb.info.width,
|
width: thumb.width,
|
||||||
height: thumb.info.height,
|
height: thumb.height,
|
||||||
size: thumb.info.size,
|
size: thumbBuffer.length,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
lastError = err;
|
lastError = err;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
restoreDefaultSkills,
|
restoreDefaultSkills,
|
||||||
restoreDefaultApplets,
|
restoreDefaultApplets,
|
||||||
} from '../../tools/vfs.js';
|
} from '../../tools/vfs.js';
|
||||||
import sharp from 'sharp';
|
import { optimizeImage } from 'wasm-image-optimization';
|
||||||
|
|
||||||
// #region Helpers
|
// #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
|
* @returns {Promise<{ buffer: Buffer, contentType: string } | null>} null when not rasterisable
|
||||||
*/
|
*/
|
||||||
async function renderThumbnail(buffer, { mime, filename }) {
|
async function renderThumbnail(buffer, { mime, filename }) {
|
||||||
const pipeline = sharp(buffer, { animated: false, failOn: 'none' });
|
|
||||||
let meta;
|
let meta;
|
||||||
try {
|
try {
|
||||||
meta = await pipeline.metadata();
|
meta = await optimizeImage({ image: buffer, format: 'none' });
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
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
|
// Downscale to fit inside THUMB_BOX without enlargement, then explicitly upscale
|
||||||
.resize({
|
// anything whose larger edge is still under THUMB_MIN_EDGE.
|
||||||
width: THUMB_BOX,
|
const downScale = Math.min(1, THUMB_BOX / origW, THUMB_BOX / origH);
|
||||||
height: THUMB_BOX,
|
let outWidth = Math.max(1, Math.round(origW * downScale));
|
||||||
fit: 'inside',
|
let outHeight = Math.max(1, Math.round(origH * downScale));
|
||||||
// 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();
|
|
||||||
|
|
||||||
let scaledMeta = await sharp(scaled).metadata();
|
const largerEdge = Math.max(outWidth, outHeight);
|
||||||
|
|
||||||
// Upscale small images so they do not render as an unreadable speck.
|
|
||||||
const largerEdge = Math.max(scaledMeta.width, scaledMeta.height);
|
|
||||||
if (largerEdge < THUMB_MIN_EDGE) {
|
if (largerEdge < THUMB_MIN_EDGE) {
|
||||||
const factor = THUMB_MIN_EDGE / largerEdge;
|
const factor = THUMB_MIN_EDGE / largerEdge;
|
||||||
scaled = await sharp(scaled)
|
outWidth = Math.max(1, Math.round(outWidth * factor));
|
||||||
.resize({
|
outHeight = Math.max(1, Math.round(outHeight * factor));
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const outWidth = scaledMeta.width;
|
|
||||||
const outHeight = scaledMeta.height;
|
|
||||||
|
|
||||||
const overlay = buildMetaOverlay(
|
const overlay = buildMetaOverlay(
|
||||||
{
|
{
|
||||||
width: meta.width,
|
width: origW,
|
||||||
height: meta.height,
|
height: origH,
|
||||||
size: buffer.length,
|
size: buffer.length,
|
||||||
mime,
|
mime,
|
||||||
filename,
|
filename,
|
||||||
},
|
},
|
||||||
outWidth,
|
outWidth,
|
||||||
outHeight
|
outHeight
|
||||||
);
|
);
|
||||||
|
|
||||||
// `null` means the thumbnail is too small to carry a legible metadata band (e.g. an extreme
|
// `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.
|
// aspect ratio collapsing to a few pixels tall). Serve the plain scaled image instead.
|
||||||
if (!overlay) {
|
if (!overlay) {
|
||||||
return {
|
const plain = await optimizeImage({
|
||||||
buffer: await sharp(scaled)[THUMB_FORMAT]({ quality: THUMB_QUALITY }).toBuffer(),
|
image: buffer,
|
||||||
contentType: `image/${THUMB_FORMAT}`,
|
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 }])
|
const scaledPng = await optimizeImage({
|
||||||
[THUMB_FORMAT]({ quality: THUMB_QUALITY })
|
image: buffer,
|
||||||
.toBuffer();
|
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(
|
||||||
|
/(<svg[^>]*>)/,
|
||||||
|
`$1<image href="data:image/png;base64,${b64}" x="0" y="0" width="${outWidth}" height="${outHeight}"/>`
|
||||||
|
);
|
||||||
|
const composite = await optimizeImage({
|
||||||
|
image: compositeSvg,
|
||||||
|
format: THUMB_FORMAT,
|
||||||
|
quality: THUMB_QUALITY,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
buffer: composite,
|
buffer: Buffer.from(composite.data),
|
||||||
contentType: `image/${THUMB_FORMAT}`,
|
contentType: `image/${THUMB_FORMAT}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export function register(app, basePath = '') {
|
|||||||
* response also carries `columns` (name/type) and `rows` (data row count). Only declared columns are
|
* 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
|
* 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,
|
* 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.
|
* 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
|
* Path traversal (`..`), null bytes, and backslashes are rejected with HTTP 400, as is a path that does
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ Applets run in an isolated worker thread pool sandbox.
|
|||||||
- `fs`: Sandboxed filesystem interface for reading/writing local company files.
|
- `fs`: Sandboxed filesystem interface for reading/writing local company files.
|
||||||
- `ExcelJS`: Pre-injected spreadsheet library.
|
- `ExcelJS`: Pre-injected spreadsheet library.
|
||||||
- `PDFDocument`: Pre-injected PDFKit library.
|
- `PDFDocument`: Pre-injected PDFKit library.
|
||||||
- `sharp`: Pre-injected image manipulation library.
|
- `optimizeImage`: Pre-injected image manipulation library (`wasm-image-optimization`).
|
||||||
- `crypto`: Node / WebCrypto API.
|
- `crypto`: Node / WebCrypto API.
|
||||||
- `console.log(...)`: returned in the tool response
|
- `console.log(...)`: returned in the tool response
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// MARK: - Imports
|
// MARK: - Imports
|
||||||
// #region Imports
|
// #region Imports
|
||||||
import sharp from 'sharp';
|
import { optimizeImage } from 'wasm-image-optimization';
|
||||||
import { encodeRGBA } from 'skelehash-wasm';
|
import { encodeRGBA } from 'skelehash-wasm';
|
||||||
import { createLogger } from '../../util/logger.js';
|
import { createLogger } from '../../util/logger.js';
|
||||||
// #endregion
|
// #endregion
|
||||||
@@ -23,21 +23,22 @@ export async function computeSkeleHash(buffer) {
|
|||||||
const base64Data = input.includes(',') ? input.split(',')[1] : input;
|
const base64Data = input.includes(',') ? input.split(',')[1] : input;
|
||||||
input = Buffer.from(base64Data, 'base64');
|
input = Buffer.from(base64Data, 'base64');
|
||||||
}
|
}
|
||||||
const { data, info } = await sharp(input)
|
const raw = await optimizeImage({
|
||||||
.rotate()
|
image: input,
|
||||||
.resize({ width: 200, height: 200, fit: 'inside' })
|
width: 200,
|
||||||
.ensureAlpha()
|
height: 200,
|
||||||
.raw()
|
fit: 'contain',
|
||||||
.toBuffer({ resolveWithObject: true });
|
format: 'raw',
|
||||||
|
});
|
||||||
|
|
||||||
const result = await encodeRGBA(info.width, info.height, data);
|
const result = await encodeRGBA(raw.width, raw.height, raw.data);
|
||||||
if (!result?.base62) {
|
if (!result?.base62) {
|
||||||
throw new Error('encodeRGBA failed to produce a base62 skelehash');
|
throw new Error('encodeRGBA failed to produce a base62 skelehash');
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
base62: result.base62,
|
base62: result.base62,
|
||||||
width: info.width,
|
width: raw.width,
|
||||||
height: info.height,
|
height: raw.height,
|
||||||
hashLength: result.hash?.length ?? 0,
|
hashLength: result.hash?.length ?? 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,18 +5,14 @@ import os from 'os';
|
|||||||
const FETCH_TIMEOUT_MS = 15000;
|
const FETCH_TIMEOUT_MS = 15000;
|
||||||
const MAX_BYTES = 50 * 1024 * 1024; // 50MB
|
const MAX_BYTES = 50 * 1024 * 1024; // 50MB
|
||||||
|
|
||||||
// The crop/trim/resize/AVIF pipeline runs up to six sequential sharp passes (PNG
|
// The crop/trim/resize/AVIF pipeline runs in a worker thread so decoding,
|
||||||
// intermediate, trim, extract, resize, AVIF encode). A sharp pipeline is owned by
|
// trimming, and AVIF encoding never stall HTTP handlers or SSE heartbeats.
|
||||||
// whichever thread created it, so running it on the main thread stalls every HTTP
|
// The pipeline lives in imageCropWorker.js and is reached through the pool below.
|
||||||
// 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_PATH = fileURLToPath(new URL('./imageCropWorker.js', import.meta.url));
|
||||||
const WORKER_BOOT_TIMEOUT_MS = 15000;
|
const WORKER_BOOT_TIMEOUT_MS = 15000;
|
||||||
const DEFAULT_JOB_TIMEOUT_MS = 30000;
|
const DEFAULT_JOB_TIMEOUT_MS = 30000;
|
||||||
// libvips already saturates cores via its own per-process thread pool, so each worker
|
// Cap at 3 workers to stay conservative on memory (each in-flight image can be tens of MB)
|
||||||
// is mostly IO/decode-bound rather than needing one thread per core. Cap at 3 to stay
|
// while still covering the realistic burst. NOTE: use os.cpus(), not process.availableParallelism() — that
|
||||||
// conservative on memory (each in-flight image can be tens of MB) while still covering
|
|
||||||
// the realistic burst. NOTE: use os.cpus(), not process.availableParallelism() — that
|
|
||||||
// helper is absent on some supported Node builds and silently collapsed this to 1.
|
// helper is absent on some supported Node builds and silently collapsed this to 1.
|
||||||
const CPU_COUNT = Math.max(1, os.cpus?.()?.length || os.availableParallelism?.() || 2);
|
const CPU_COUNT = Math.max(1, os.cpus?.()?.length || os.availableParallelism?.() || 2);
|
||||||
const POOL_SIZE = Math.max(1, Math.min(3, CPU_COUNT - 1));
|
const POOL_SIZE = Math.max(1, Math.min(3, CPU_COUNT - 1));
|
||||||
@@ -67,8 +63,8 @@ function spawnWorker() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onBooted(msg) {
|
function onBooted(msg) {
|
||||||
// The worker sends an explicit `{ ready: true }` once its imports (notably
|
// The worker sends an explicit `{ ready: true }` once its imports
|
||||||
// sharp/libvips) have resolved. Without this handshake there is no first
|
// have resolved. Without this handshake there is no first
|
||||||
// message to await, so a worker that fails to boot would only surface later
|
// message to await, so a worker that fails to boot would only surface later
|
||||||
// as a confusing per-job failure.
|
// as a confusing per-job failure.
|
||||||
if (!msg || msg.ready !== true) return;
|
if (!msg || msg.ready !== true) return;
|
||||||
@@ -158,7 +154,7 @@ function runJob(entry, inputBuffer, timeoutMs) {
|
|||||||
let settled = false;
|
let settled = false;
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
// Terminate first, then report. Leaving a worker blocked in libvips would let
|
// Terminate first, then report. Leaving a worker blocked in WASM would let
|
||||||
// it keep burning a core long after the HTTP request has been answered, and
|
// 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.
|
// its eventual reply would then arrive against an already-settled job.
|
||||||
try { entry.worker.terminate(); } catch { /* already closed */ }
|
try { entry.worker.terminate(); } catch { /* already closed */ }
|
||||||
@@ -380,8 +376,8 @@ export async function crawlImage(url, { signal } = {}) {
|
|||||||
* actually trimmed, never enlarging beyond the original), resizes to max 1000px,
|
* actually trimmed, never enlarging beyond the original), resizes to max 1000px,
|
||||||
* and converts to AVIF.
|
* and converts to AVIF.
|
||||||
*
|
*
|
||||||
* Runs the sharp pipeline in a pooled worker thread (see imageCropWorker.js) so the
|
* Runs the image 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
|
* 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
|
* 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.
|
* `Failed to crawl image:` prefix check in /serper/image) keep working unchanged.
|
||||||
*
|
*
|
||||||
@@ -400,7 +396,7 @@ export async function cropAndConvertToAvif(inputBuffer, { timeout = DEFAULT_JOB_
|
|||||||
|
|
||||||
// Grow the pool on demand, then queue. enqueueJob/drainQueue hand the job to the
|
// 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
|
// first idle worker, so an idle worker is never leased twice and a saturated pool
|
||||||
// simply waits instead of piling up sharp pipelines.
|
// simply waits instead of piling up concurrent pipelines.
|
||||||
await acquireWorker();
|
await acquireWorker();
|
||||||
return enqueueJob(inputBuffer, timeout);
|
return enqueueJob(inputBuffer, timeout);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Worker-thread entry point for the sharp crop/trim/resize/AVIF pipeline.
|
* Worker-thread entry point for the wasm-image-optimization crop/trim/resize/AVIF pipeline.
|
||||||
*
|
*
|
||||||
* This pipeline runs up to six sequential sharp passes (PNG intermediate, trim,
|
* Running the decode, pixel-trim scan, crop/resize, and AVIF encode in a worker thread
|
||||||
* extract, resize, AVIF encode) whose JS glue cost lands on the thread that owns
|
* keeps the cost off the main event loop, which otherwise stalls HTTP handlers and
|
||||||
* the pipeline. Running it here keeps that cost off the main event loop, which
|
* every SSE client (heartbeats included).
|
||||||
* otherwise stalls HTTP handlers and every SSE client (heartbeats included).
|
|
||||||
*
|
*
|
||||||
* Protocol: the parent sends `{ id, buffer }` (an ArrayBuffer, transferred) and
|
* Protocol: the parent sends `{ id, buffer }` (an ArrayBuffer, transferred) and
|
||||||
* the worker replies `{ id, ok: true, buffer, width, height, size }` (buffer
|
* the worker replies `{ id, ok: true, buffer, width, height, size }` (buffer
|
||||||
@@ -14,13 +13,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { parentPort } from 'worker_threads';
|
import { parentPort } from 'worker_threads';
|
||||||
import sharp from 'sharp';
|
import { optimizeImage } from 'wasm-image-optimization';
|
||||||
|
|
||||||
const MAX_EDGE = 1000;
|
const MAX_EDGE = 1000;
|
||||||
const TRIM_THRESHOLD = 10;
|
const TRIM_THRESHOLD = 10;
|
||||||
const RESTORE_MARGIN = 5; // px restored per edge after trimming (up to what was trimmed)
|
const RESTORE_MARGIN = 5; // px restored per edge after trimming (up to what was trimmed)
|
||||||
const AVIF_QUALITY = 80;
|
const AVIF_QUALITY = 80;
|
||||||
const AVIF_EFFORT = 4;
|
const AVIF_SPEED = 6;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Copies a Buffer into a standalone, transferable ArrayBuffer.
|
* Copies a Buffer into a standalone, transferable ArrayBuffer.
|
||||||
@@ -47,90 +46,145 @@ function toTransferable(buffer) {
|
|||||||
* @returns {Promise<{ buffer: Buffer, width: number, height: number, size: number }>}
|
* @returns {Promise<{ buffer: Buffer, width: number, height: number, size: number }>}
|
||||||
*/
|
*/
|
||||||
async function cropAndConvertToAvif(inputBuffer) {
|
async function cropAndConvertToAvif(inputBuffer) {
|
||||||
// 1. Initial metadata and orientation correction
|
// 1. Decode to oriented raw RGBA pixels
|
||||||
const baseSharp = sharp(inputBuffer, { failOn: 'none' }).rotate();
|
const raw = await optimizeImage({
|
||||||
const initialMeta = await baseSharp.metadata();
|
image: inputBuffer,
|
||||||
const hasAlpha = Boolean(initialMeta.hasAlpha);
|
format: 'raw',
|
||||||
|
});
|
||||||
|
const origW = raw.width;
|
||||||
|
const origH = raw.height;
|
||||||
|
const pixels = raw.data;
|
||||||
|
|
||||||
// Use PNG intermediate for trimming passes to preserve alpha
|
let hasAlpha = false;
|
||||||
const originalBuffer = await baseSharp.png().toBuffer();
|
for (let i = 3; i < pixels.length; i += 4) {
|
||||||
const origMeta = await sharp(originalBuffer).metadata();
|
if (pixels[i] < 255) {
|
||||||
|
hasAlpha = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Cumulative crop box in original (oriented) pixel coordinates
|
// Cumulative crop box in original (oriented) pixel coordinates
|
||||||
let box = { left: 0, top: 0, width: origMeta.width, height: origMeta.height };
|
let box = { left: 0, top: 0, width: origW, height: origH };
|
||||||
let currentBuffer = originalBuffer;
|
|
||||||
|
|
||||||
const trimPass = async (background) => {
|
const isTransparent = (idx) => pixels[idx + 3] <= TRIM_THRESHOLD;
|
||||||
try {
|
const isWhite = (idx) => {
|
||||||
const trimmed = await sharp(currentBuffer)
|
const a = pixels[idx + 3];
|
||||||
.trim({ background, threshold: TRIM_THRESHOLD })
|
if (a < 255 - TRIM_THRESHOLD) return false;
|
||||||
.png()
|
return (
|
||||||
.toBuffer({ resolveWithObject: true });
|
pixels[idx] >= 255 - TRIM_THRESHOLD &&
|
||||||
if (trimmed.info.width > 0 && trimmed.info.height > 0) {
|
pixels[idx + 1] >= 255 - TRIM_THRESHOLD &&
|
||||||
currentBuffer = trimmed.data;
|
pixels[idx + 2] >= 255 - TRIM_THRESHOLD
|
||||||
box = {
|
);
|
||||||
left: box.left - (trimmed.info.trimOffsetLeft ?? 0),
|
};
|
||||||
top: box.top - (trimmed.info.trimOffsetTop ?? 0),
|
|
||||||
width: trimmed.info.width,
|
const trimPass = (isBg) => {
|
||||||
height: trimmed.info.height,
|
let minX = box.left;
|
||||||
};
|
let maxX = box.left + box.width - 1;
|
||||||
|
let minY = box.top;
|
||||||
|
let maxY = box.top + box.height - 1;
|
||||||
|
|
||||||
|
while (minY <= maxY) {
|
||||||
|
let rowBg = true;
|
||||||
|
for (let x = minX; x <= maxX; x++) {
|
||||||
|
if (!isBg((minY * origW + x) * 4)) {
|
||||||
|
rowBg = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
if (!rowBg) break;
|
||||||
// Keep currentBuffer
|
minY++;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (maxY >= minY) {
|
||||||
|
let rowBg = true;
|
||||||
|
for (let x = minX; x <= maxX; x++) {
|
||||||
|
if (!isBg((maxY * origW + x) * 4)) {
|
||||||
|
rowBg = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!rowBg) break;
|
||||||
|
maxY--;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (minX <= maxX) {
|
||||||
|
let colBg = true;
|
||||||
|
for (let y = minY; y <= maxY; y++) {
|
||||||
|
if (!isBg((y * origW + minX) * 4)) {
|
||||||
|
colBg = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!colBg) break;
|
||||||
|
minX++;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (maxX >= minX) {
|
||||||
|
let colBg = true;
|
||||||
|
for (let y = minY; y <= maxY; y++) {
|
||||||
|
if (!isBg((y * origW + maxX) * 4)) {
|
||||||
|
colBg = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!colBg) break;
|
||||||
|
maxX--;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxX >= minX && maxY >= minY) {
|
||||||
|
box = {
|
||||||
|
left: minX,
|
||||||
|
top: minY,
|
||||||
|
width: maxX - minX + 1,
|
||||||
|
height: maxY - minY + 1,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 2. Step A: Trim transparent borders if alpha is present
|
// 2. Step A: Trim transparent borders if alpha is present
|
||||||
if (hasAlpha) await trimPass({ r: 0, g: 0, b: 0, alpha: 0 });
|
if (hasAlpha) trimPass(isTransparent);
|
||||||
|
|
||||||
// 3. Step B: Trim white borders
|
// 3. Step B: Trim white borders
|
||||||
await trimPass('#ffffff');
|
trimPass(isWhite);
|
||||||
|
|
||||||
// 4. Step C: Trim transparent borders again (handles white border containing transparent padding)
|
// 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 });
|
if (hasAlpha) trimPass(isTransparent);
|
||||||
|
|
||||||
// 4b. Restore up to RESTORE_MARGIN px per edge from the original, clamped to original bounds
|
// 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 left = Math.max(0, box.left - RESTORE_MARGIN);
|
||||||
const top = Math.max(0, box.top - RESTORE_MARGIN);
|
const top = Math.max(0, box.top - RESTORE_MARGIN);
|
||||||
const right = Math.min(origMeta.width, box.left + box.width + RESTORE_MARGIN);
|
const right = Math.min(origW, box.left + box.width + RESTORE_MARGIN);
|
||||||
const bottom = Math.min(origMeta.height, box.top + box.height + RESTORE_MARGIN);
|
const bottom = Math.min(origH, box.top + box.height + RESTORE_MARGIN);
|
||||||
if (left !== box.left || top !== box.top || right - left !== box.width || bottom - top !== box.height) {
|
const cropW = right - left;
|
||||||
try {
|
const cropH = bottom - top;
|
||||||
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
|
// 5. Resize to max 1000px without enlargement and convert to AVIF
|
||||||
let finalPipeline = sharp(currentBuffer);
|
const needsCrop = left > 0 || top > 0 || cropW < origW || cropH < origH;
|
||||||
if (currentMeta.width > MAX_EDGE || currentMeta.height > MAX_EDGE) {
|
const needsResize = cropW > MAX_EDGE || cropH > MAX_EDGE;
|
||||||
finalPipeline = finalPipeline.resize(MAX_EDGE, MAX_EDGE, {
|
const scale = needsResize ? Math.min(MAX_EDGE / cropW, MAX_EDGE / cropH) : 1;
|
||||||
fit: 'inside',
|
|
||||||
withoutEnlargement: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. Convert to AVIF
|
const avifResult = await optimizeImage({
|
||||||
const avifResult = await finalPipeline
|
image: inputBuffer,
|
||||||
.avif({ quality: AVIF_QUALITY, effort: AVIF_EFFORT })
|
crop: needsCrop ? { x: left, y: top, width: cropW, height: cropH } : undefined,
|
||||||
.toBuffer({ resolveWithObject: true });
|
width: needsResize ? Math.max(1, Math.round(cropW * scale)) : undefined,
|
||||||
|
height: needsResize ? Math.max(1, Math.round(cropH * scale)) : undefined,
|
||||||
|
fit: 'fill',
|
||||||
|
format: 'avif',
|
||||||
|
quality: AVIF_QUALITY,
|
||||||
|
speed: AVIF_SPEED,
|
||||||
|
});
|
||||||
|
|
||||||
|
const outBuffer = Buffer.from(avifResult.data);
|
||||||
return {
|
return {
|
||||||
buffer: avifResult.data,
|
buffer: outBuffer,
|
||||||
width: avifResult.info.width,
|
width: avifResult.width,
|
||||||
height: avifResult.info.height,
|
height: avifResult.height,
|
||||||
size: avifResult.info.size,
|
size: outBuffer.length,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Boot handshake. The parent awaits this before queuing the first job, so a worker
|
// 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
|
// whose imports fail surfaces as a start-up error rather than as an
|
||||||
// inexplicable per-job failure.
|
// inexplicable per-job failure.
|
||||||
parentPort.postMessage({ ready: true });
|
parentPort.postMessage({ ready: true });
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Worker } from 'worker_threads';
|
|||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import env from '../config/env.js';
|
import env from '../config/env.js';
|
||||||
|
|
||||||
// The sandbox worker pays a large fixed startup cost: importing vm2, sharp, exceljs, pdfkit,
|
// The sandbox worker pays a large fixed startup cost: importing vm2, wasm-image-optimization, exceljs, pdfkit,
|
||||||
// react-canvas-label-editor AND `vfs.js` (which pulls in libdashstore) costs ~700 ms in a fresh
|
// react-canvas-label-editor AND `vfs.js` (which pulls in libdashstore) costs ~700 ms in a fresh
|
||||||
// thread, while the snippet itself runs in ~2 ms. ESM modules are never shared between worker
|
// thread, while the snippet itself runs in ~2 ms. ESM modules are never shared between worker
|
||||||
// threads, so spawning one thread per applet run re-paid that cost on every request.
|
// threads, so spawning one thread per applet run re-paid that cost on every request.
|
||||||
@@ -17,7 +17,7 @@ const WORKER_BOOT_TIMEOUT_MS = 15000;
|
|||||||
// Two threads per company rides out the common "run it, then run it again" loop without queueing.
|
// Two threads per company rides out the common "run it, then run it again" loop without queueing.
|
||||||
const MAX_WORKERS_PER_COMPANY = 2;
|
const MAX_WORKERS_PER_COMPANY = 2;
|
||||||
// ...but the process is shared by every company, so the total stays bounded. Each thread holds
|
// ...but the process is shared by every company, so the total stays bounded. Each thread holds
|
||||||
// sharp/libvips plus a NodeVM, so 5 is a deliberate memory ceiling rather than a core count.
|
// WASM modules plus a NodeVM, so 5 is a deliberate memory ceiling rather than a core count.
|
||||||
const MAX_WORKERS_TOTAL = 5;
|
const MAX_WORKERS_TOTAL = 5;
|
||||||
|
|
||||||
const DEFAULT_BUFFER_LIMIT = 32 * 1024 * 1024;
|
const DEFAULT_BUFFER_LIMIT = 32 * 1024 * 1024;
|
||||||
@@ -228,7 +228,7 @@ function runJobOnWorker(entry, job, timeoutMs) {
|
|||||||
let settled = false;
|
let settled = false;
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
// Terminate first, then report. A snippet stuck in a native call (libvips, fetch)
|
// Terminate first, then report. A snippet stuck in a WASM or I/O call (optimizeImage, fetch)
|
||||||
// would otherwise keep burning the thread after the caller has moved on, and its
|
// would otherwise keep burning the thread after the caller has moved on, and its
|
||||||
// eventual reply would arrive against an already-settled job.
|
// eventual reply would arrive against an already-settled job.
|
||||||
try { entry.worker.terminate(); } catch { /* already closed */ }
|
try { entry.worker.terminate(); } catch { /* already closed */ }
|
||||||
|
|||||||
@@ -1,9 +1,59 @@
|
|||||||
import sharp from 'sharp';
|
import zlib from 'node:zlib';
|
||||||
|
import { optimizeImage } from 'wasm-image-optimization';
|
||||||
import { renderLabel } from 'react-canvas-label-editor';
|
import { renderLabel } from 'react-canvas-label-editor';
|
||||||
|
|
||||||
const THUMB_W = 150;
|
const THUMB_W = 150;
|
||||||
const THUMB_H = 80;
|
const THUMB_H = 80;
|
||||||
|
|
||||||
|
const CRC_TABLE = (() => {
|
||||||
|
const table = new Uint32Array(256);
|
||||||
|
for (let n = 0; n < 256; n++) {
|
||||||
|
let c = n;
|
||||||
|
for (let k = 0; k < 8; k++) {
|
||||||
|
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||||
|
}
|
||||||
|
table[n] = c;
|
||||||
|
}
|
||||||
|
return table;
|
||||||
|
})();
|
||||||
|
|
||||||
|
function crc32(buf) {
|
||||||
|
let c = 0xffffffff;
|
||||||
|
for (let i = 0; i < buf.length; i++) {
|
||||||
|
c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
||||||
|
}
|
||||||
|
return (c ^ 0xffffffff) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pngChunk(type, data) {
|
||||||
|
const b = Buffer.alloc(12 + data.length);
|
||||||
|
b.writeUInt32BE(data.length, 0);
|
||||||
|
b.write(type, 4, 4, 'ascii');
|
||||||
|
data.copy(b, 8);
|
||||||
|
b.writeUInt32BE(crc32(b.subarray(4, 8 + data.length)), 8 + data.length);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeRgbaToPng(w, h, rgba) {
|
||||||
|
const raw = Buffer.alloc((w * 4 + 1) * h);
|
||||||
|
for (let y = 0; y < h; y++) {
|
||||||
|
raw[y * (w * 4 + 1)] = 0;
|
||||||
|
rgba.copy(raw, y * (w * 4 + 1) + 1, y * w * 4, (y + 1) * w * 4);
|
||||||
|
}
|
||||||
|
const idat = zlib.deflateSync(raw, { level: 9 });
|
||||||
|
const ihdr = Buffer.alloc(13);
|
||||||
|
ihdr.writeUInt32BE(w, 0);
|
||||||
|
ihdr.writeUInt32BE(h, 4);
|
||||||
|
ihdr[8] = 8;
|
||||||
|
ihdr[9] = 6; // RGBA
|
||||||
|
return Buffer.concat([
|
||||||
|
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
|
||||||
|
pngChunk('IHDR', ihdr),
|
||||||
|
pngChunk('IDAT', idat),
|
||||||
|
pngChunk('IEND', Buffer.alloc(0)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders a 150×80 thumbnail for a label template.
|
* Renders a 150×80 thumbnail for a label template.
|
||||||
*
|
*
|
||||||
@@ -32,17 +82,35 @@ export async function renderThumb({ width, height, objects }) {
|
|||||||
|
|
||||||
// 2. Resize to fit within 150×80, preserving aspect ratio.
|
// 2. Resize to fit within 150×80, preserving aspect ratio.
|
||||||
// Transparent background for letterbox padding.
|
// Transparent background for letterbox padding.
|
||||||
const { data, info } = await sharp(nativePng)
|
const scaled = await optimizeImage({
|
||||||
.ensureAlpha()
|
image: nativePng,
|
||||||
.resize(THUMB_W, THUMB_H, {
|
width: THUMB_W,
|
||||||
fit: 'contain',
|
height: THUMB_H,
|
||||||
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
fit: 'contain',
|
||||||
kernel: sharp.kernel.lanczos3,
|
format: 'raw',
|
||||||
})
|
});
|
||||||
.raw()
|
|
||||||
.toBuffer({ resolveWithObject: true });
|
|
||||||
|
|
||||||
const { width: w, height: h } = info;
|
const w = THUMB_W;
|
||||||
|
const h = THUMB_H;
|
||||||
|
const sw = scaled.width;
|
||||||
|
const sh = scaled.height;
|
||||||
|
const offsetX = Math.floor((w - sw) / 2);
|
||||||
|
const offsetY = Math.floor((h - sh) / 2);
|
||||||
|
const data = Buffer.alloc(w * h * 4);
|
||||||
|
for (let y = 0; y < sh; y++) {
|
||||||
|
const dy = y + offsetY;
|
||||||
|
if (dy < 0 || dy >= h) continue;
|
||||||
|
for (let x = 0; x < sw; x++) {
|
||||||
|
const dx = x + offsetX;
|
||||||
|
if (dx < 0 || dx >= w) continue;
|
||||||
|
const srcIdx = (y * sw + x) * 4;
|
||||||
|
const dstIdx = (dy * w + dx) * 4;
|
||||||
|
data[dstIdx] = scaled.data[srcIdx];
|
||||||
|
data[dstIdx + 1] = scaled.data[srcIdx + 1];
|
||||||
|
data[dstIdx + 2] = scaled.data[srcIdx + 2];
|
||||||
|
data[dstIdx + 3] = scaled.data[srcIdx + 3];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 3. Per-pixel: transparent letterbox remains transparent (alpha=0),
|
// 3. Per-pixel: transparent letterbox remains transparent (alpha=0),
|
||||||
// label area is opaque (alpha=255) with 4-bit greyscale (16 levels).
|
// label area is opaque (alpha=255) with 4-bit greyscale (16 levels).
|
||||||
@@ -68,10 +136,12 @@ export async function renderThumb({ width, height, objects }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Encode as palette PNG with transparency support (16 greys + 1 transparent entry).
|
// 4. Encode as PNG via wasm-image-optimization.
|
||||||
const thumb = await sharp(rgba, { raw: { width: w, height: h, channels: 4 } })
|
const rawPng = encodeRgbaToPng(w, h, rgba);
|
||||||
.png({ palette: true, colours: 17, compressionLevel: 9 })
|
const thumb = await optimizeImage({
|
||||||
.toBuffer();
|
image: rawPng,
|
||||||
|
format: 'png',
|
||||||
|
});
|
||||||
|
|
||||||
return thumb;
|
return Buffer.from(thumb.data);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import PDFDocument from 'pdfkit';
|
import PDFDocument from 'pdfkit';
|
||||||
import { createCanvas } from 'canvas';
|
import { createCanvas } from 'canvas';
|
||||||
import JsBarcode from 'jsbarcode';
|
import JsBarcode from 'jsbarcode';
|
||||||
import sharp from 'sharp';
|
import { optimizeImage } from 'wasm-image-optimization';
|
||||||
import { getStore } from '../db/store.js';
|
import { getStore } from '../db/store.js';
|
||||||
import * as dbOrder from '../db/order.js';
|
import * as dbOrder from '../db/order.js';
|
||||||
import { emit } from '../api/sse.js';
|
import { emit } from '../api/sse.js';
|
||||||
@@ -127,14 +127,16 @@ export function generateBarcodePng(text, preferredFormat = 'CODE128', options =
|
|||||||
async function resolveThumbPng(store, googleAuthId, picHash, smallthumbbinary) {
|
async function resolveThumbPng(store, googleAuthId, picHash, smallthumbbinary) {
|
||||||
if (smallthumbbinary) {
|
if (smallthumbbinary) {
|
||||||
try {
|
try {
|
||||||
return await sharp(smallthumbbinary).png().toBuffer();
|
const res = await optimizeImage({ image: smallthumbbinary, format: 'png' });
|
||||||
|
return Buffer.from(res.data);
|
||||||
} catch { /* fallthrough */ }
|
} catch { /* fallthrough */ }
|
||||||
}
|
}
|
||||||
if (picHash) {
|
if (picHash) {
|
||||||
try {
|
try {
|
||||||
const thumb = await store.pic.getThumbByHash(googleAuthId, picHash);
|
const thumb = await store.pic.getThumbByHash(googleAuthId, picHash);
|
||||||
if (thumb?.buffer) {
|
if (thumb?.buffer) {
|
||||||
return await sharp(thumb.buffer).png().toBuffer();
|
const res = await optimizeImage({ image: thumb.buffer, format: 'png' });
|
||||||
|
return Buffer.from(res.data);
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import sharp from 'sharp';
|
import { optimizeImage } from 'wasm-image-optimization';
|
||||||
import { defineTool, ToolError, OpenRouterProvider } from 'libllmorbiter';
|
import { defineTool, ToolError, OpenRouterProvider } from 'libllmorbiter';
|
||||||
import env from '../config/env.js';
|
import env from '../config/env.js';
|
||||||
import { getImageByHash } from '../db/pic.js';
|
import { getImageByHash } from '../db/pic.js';
|
||||||
@@ -130,7 +130,7 @@ function createLabelSvg(name, width, height, fontSize = 11) {
|
|||||||
*/
|
*/
|
||||||
async function renderGrid(items, config) {
|
async function renderGrid(items, config) {
|
||||||
const { cols, totalWidth, totalHeight } = config;
|
const { cols, totalWidth, totalHeight } = config;
|
||||||
const overlays = [];
|
const elements = [];
|
||||||
|
|
||||||
for (let i = 0; i < items.length; i += 1) {
|
for (let i = 0; i < items.length; i += 1) {
|
||||||
const item = items[i];
|
const item = items[i];
|
||||||
@@ -145,36 +145,49 @@ async function renderGrid(items, config) {
|
|||||||
const maxW = cellW - 2 * LABEL_PAD;
|
const maxW = cellW - 2 * LABEL_PAD;
|
||||||
const maxH = cellH - LABEL_HEIGHT - 2 * LABEL_PAD;
|
const maxH = cellH - LABEL_HEIGHT - 2 * LABEL_PAD;
|
||||||
|
|
||||||
const resized = await sharp(item.rawBuffer)
|
const meta = await optimizeImage({ image: item.rawBuffer, format: 'none' });
|
||||||
.rotate()
|
const srcW = meta.originalWidth || maxW;
|
||||||
.resize(maxW, maxH, { fit: 'inside', withoutEnlargement: true })
|
const srcH = meta.originalHeight || maxH;
|
||||||
.toBuffer({ resolveWithObject: true });
|
const scale = Math.min(1, maxW / srcW, maxH / srcH);
|
||||||
|
const targetW = Math.max(1, Math.round(srcW * scale));
|
||||||
|
const targetH = Math.max(1, Math.round(srcH * scale));
|
||||||
|
|
||||||
overlays.push({
|
const resized = await optimizeImage({
|
||||||
input: resized.data,
|
image: item.rawBuffer,
|
||||||
left: cellX + LABEL_PAD + Math.round((maxW - resized.info.width) / 2),
|
width: scale < 1 ? targetW : undefined,
|
||||||
top: cellY + LABEL_PAD + Math.round((maxH - resized.info.height) / 2),
|
height: scale < 1 ? targetH : undefined,
|
||||||
|
fit: 'fill',
|
||||||
|
format: 'png',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const imgLeft = cellX + LABEL_PAD + Math.round((maxW - resized.width) / 2);
|
||||||
|
const imgTop = cellY + LABEL_PAD + Math.round((maxH - resized.height) / 2);
|
||||||
|
const b64 = Buffer.from(resized.data).toString('base64');
|
||||||
|
elements.push(
|
||||||
|
`<image href="data:image/png;base64,${b64}" x="${imgLeft}" y="${imgTop}" width="${resized.width}" height="${resized.height}"/>`
|
||||||
|
);
|
||||||
|
|
||||||
const labelW = cellW - 2 * LABEL_PAD;
|
const labelW = cellW - 2 * LABEL_PAD;
|
||||||
overlays.push({
|
const labelLeft = cellX + LABEL_PAD;
|
||||||
input: createLabelSvg(item.name, labelW, LABEL_HEIGHT, cols === 2 ? 12 : 11),
|
const labelTop = cellY + cellH - LABEL_HEIGHT - LABEL_PAD;
|
||||||
left: cellX + LABEL_PAD,
|
const labelSvg = createLabelSvg(item.name, labelW, LABEL_HEIGHT, cols === 2 ? 12 : 11)
|
||||||
top: cellY + cellH - LABEL_HEIGHT - LABEL_PAD,
|
.toString('utf8')
|
||||||
});
|
.replace(/^<svg[^>]*>/, `<svg x="${labelLeft}" y="${labelTop}" width="${labelW}" height="${LABEL_HEIGHT}">`);
|
||||||
|
elements.push(labelSvg);
|
||||||
}
|
}
|
||||||
|
|
||||||
return sharp({
|
const gridSvg =
|
||||||
create: {
|
`<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${totalHeight}">` +
|
||||||
width: totalWidth,
|
`<rect width="${totalWidth}" height="${totalHeight}" fill="rgb(20,21,24)"/>` +
|
||||||
height: totalHeight,
|
elements.join('') +
|
||||||
channels: 4,
|
`</svg>`;
|
||||||
background: { r: 20, g: 21, b: 24, alpha: 1 },
|
|
||||||
},
|
const out = await optimizeImage({
|
||||||
})
|
image: gridSvg,
|
||||||
.composite(overlays)
|
format: 'webp',
|
||||||
.webp({ quality: WEBP_QUALITY })
|
quality: WEBP_QUALITY,
|
||||||
.toBuffer();
|
});
|
||||||
|
return Buffer.from(out.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -351,11 +364,19 @@ export function createAnalyzeImageTool({ googleAuthId, model }) {
|
|||||||
const shape = resolveGrid(items.length, grid);
|
const shape = resolveGrid(items.length, grid);
|
||||||
let webp;
|
let webp;
|
||||||
if (items.length === 1 && shape.name === '1x1') {
|
if (items.length === 1 && shape.name === '1x1') {
|
||||||
webp = await sharp(items[0].rawBuffer)
|
const meta = await optimizeImage({ image: items[0].rawBuffer, format: 'none' });
|
||||||
.rotate()
|
const srcW = meta.originalWidth || SINGLE_BOX;
|
||||||
.resize(SINGLE_BOX, SINGLE_BOX, { fit: 'inside', withoutEnlargement: true })
|
const srcH = meta.originalHeight || SINGLE_BOX;
|
||||||
.webp({ quality: WEBP_QUALITY })
|
const scale = Math.min(1, SINGLE_BOX / srcW, SINGLE_BOX / srcH);
|
||||||
.toBuffer();
|
const res = await optimizeImage({
|
||||||
|
image: items[0].rawBuffer,
|
||||||
|
width: scale < 1 ? Math.max(1, Math.round(srcW * scale)) : undefined,
|
||||||
|
height: scale < 1 ? Math.max(1, Math.round(srcH * scale)) : undefined,
|
||||||
|
fit: 'fill',
|
||||||
|
format: 'webp',
|
||||||
|
quality: WEBP_QUALITY,
|
||||||
|
});
|
||||||
|
webp = Buffer.from(res.data);
|
||||||
} else {
|
} else {
|
||||||
webp = await renderGrid(items, GRID_CONFIGS[shape.name]);
|
webp = await renderGrid(items, GRID_CONFIGS[shape.name]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import sharp from 'sharp';
|
import { optimizeImage } from 'wasm-image-optimization';
|
||||||
import { defineTool, ToolError } from 'libllmorbiter';
|
import { defineTool, ToolError } from 'libllmorbiter';
|
||||||
import env from '../config/env.js';
|
import env from '../config/env.js';
|
||||||
import { getImageByHash } from '../db/pic.js';
|
import { getImageByHash } from '../db/pic.js';
|
||||||
@@ -201,26 +201,46 @@ async function loadAttachmentBytes(targetPath, vfsRoot, googleAuthId) {
|
|||||||
*/
|
*/
|
||||||
async function processAttachmentToDataUrl(rawBuffer, high) {
|
async function processAttachmentToDataUrl(rawBuffer, high) {
|
||||||
const maxEdge = high ? 1536 : 512;
|
const maxEdge = high ? 1536 : 512;
|
||||||
const metadata = await sharp(rawBuffer).metadata();
|
const raw = await optimizeImage({ image: rawBuffer, format: 'raw' });
|
||||||
const hasAlpha = Boolean(metadata.hasAlpha);
|
let hasAlpha = false;
|
||||||
|
for (let i = 3; i < raw.data.length; i += 4) {
|
||||||
|
if (raw.data[i] < 255) {
|
||||||
|
hasAlpha = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const srcW = raw.originalWidth || raw.width || 0;
|
||||||
|
const srcH = raw.originalHeight || raw.height || 0;
|
||||||
|
const needsResize = srcW > maxEdge || srcH > maxEdge;
|
||||||
|
const scale = needsResize ? Math.min(maxEdge / srcW, maxEdge / srcH) : 1;
|
||||||
|
const width = needsResize ? Math.max(1, Math.round(srcW * scale)) : undefined;
|
||||||
|
const height = needsResize ? Math.max(1, Math.round(srcH * scale)) : undefined;
|
||||||
|
|
||||||
let mime = 'image/jpeg';
|
let mime = 'image/jpeg';
|
||||||
let outputBuffer;
|
let outputBuffer;
|
||||||
|
|
||||||
if (hasAlpha) {
|
if (hasAlpha) {
|
||||||
mime = 'image/png';
|
mime = 'image/png';
|
||||||
outputBuffer = await sharp(rawBuffer)
|
const res = await optimizeImage({
|
||||||
.rotate()
|
image: rawBuffer,
|
||||||
.resize(maxEdge, maxEdge, { fit: 'inside', withoutEnlargement: true })
|
width,
|
||||||
.png({ compressionLevel: high ? 6 : 9 })
|
height,
|
||||||
.toBuffer();
|
fit: 'fill',
|
||||||
|
format: 'png',
|
||||||
|
});
|
||||||
|
outputBuffer = Buffer.from(res.data);
|
||||||
} else {
|
} else {
|
||||||
const quality = high ? 90 : 70;
|
const quality = high ? 90 : 70;
|
||||||
outputBuffer = await sharp(rawBuffer)
|
const res = await optimizeImage({
|
||||||
.rotate()
|
image: rawBuffer,
|
||||||
.resize(maxEdge, maxEdge, { fit: 'inside', withoutEnlargement: true })
|
width,
|
||||||
.jpeg({ quality })
|
height,
|
||||||
.toBuffer();
|
fit: 'fill',
|
||||||
|
format: 'jpeg',
|
||||||
|
quality,
|
||||||
|
});
|
||||||
|
outputBuffer = Buffer.from(res.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
return `data:${mime};base64,${outputBuffer.toString('base64')}`;
|
return `data:${mime};base64,${outputBuffer.toString('base64')}`;
|
||||||
@@ -447,10 +467,12 @@ export function createEditImageTool({ googleAuthId, model: fallbackModel }) {
|
|||||||
// ── Convert output to AVIF format ─────────────────────────────
|
// ── Convert output to AVIF format ─────────────────────────────
|
||||||
let finalBuffer;
|
let finalBuffer;
|
||||||
try {
|
try {
|
||||||
finalBuffer = await sharp(resultBuffer)
|
const encoded = await optimizeImage({
|
||||||
.rotate()
|
image: resultBuffer,
|
||||||
.avif({ quality: AVIF_QUALITY })
|
format: 'avif',
|
||||||
.toBuffer();
|
quality: AVIF_QUALITY,
|
||||||
|
});
|
||||||
|
finalBuffer = Buffer.from(encoded.data);
|
||||||
} catch (convErr) {
|
} catch (convErr) {
|
||||||
throw new ToolError(`edit_image: failed to encode output image to AVIF: ${convErr.message}`);
|
throw new ToolError(`edit_image: failed to encode output image to AVIF: ${convErr.message}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { parentPort, workerData } from 'worker_threads';
|
import { parentPort, workerData } from 'worker_threads';
|
||||||
import { NodeVM } from 'vm2';
|
import { NodeVM } from 'vm2';
|
||||||
import sharp from 'sharp';
|
import { optimizeImage } from 'wasm-image-optimization';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fsModule from 'fs';
|
import fsModule from 'fs';
|
||||||
import { validateObjects, validateObject } from 'react-canvas-label-editor';
|
import { validateObjects, validateObject } from 'react-canvas-label-editor';
|
||||||
@@ -149,65 +149,23 @@ function bridgeCrypto() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const bridgedCrypto = bridgeCrypto();
|
const bridgedCrypto = bridgeCrypto();
|
||||||
// `sharp` writes files DIRECTLY via libvips, bypassing Node's `fs`. Injecting the raw library
|
// `optimizeImage` from `wasm-image-optimization` operates purely in-memory on buffers/SVG strings.
|
||||||
// therefore lets a snippet write anywhere the server process can — a relative path lands in
|
// We wrap it so that a VFS relative path string (when not inline SVG) is resolved through `vfs`.
|
||||||
// the server's CWD and an absolute/`../` path escapes the VFS entirely. We wrap the two file
|
function bridgeOptimizeImage(vfs) {
|
||||||
// writing entry points (`toFile` and `toFile` on the constructor, plus `toBuffer` passthrough)
|
return async function sandboxOptimizeImage(params) {
|
||||||
// so output paths are resolved through the VFS containment rules.
|
if (!params || typeof params !== 'object') {
|
||||||
//
|
throw new TypeError('optimizeImage(): params object is required');
|
||||||
// `toFile` keeps sharp's real signature/return value; only the path is redirected.
|
|
||||||
function bridgeSharp(vfs) {
|
|
||||||
const resolveOut = (p) => {
|
|
||||||
if (typeof p !== 'string' && !(p && typeof p.toString === 'function')) {
|
|
||||||
throw new TypeError('sharp.toFile(): output path must be a string');
|
|
||||||
}
|
|
||||||
const diskPath = vfs.resolveWritePath(String(p));
|
|
||||||
|
|
||||||
// libvips creates missing parent directories itself, so `toFile('a/b/c.png')` would
|
|
||||||
// silently materialise the whole `a/b/` tree. Node's `fs.writeFileSync` instead fails
|
|
||||||
// with ENOENT. Enforce the fs semantics here so both write paths behave identically and
|
|
||||||
// a typo'd path reports an error instead of quietly succeeding.
|
|
||||||
const dir = path.dirname(diskPath);
|
|
||||||
if (!fsModule.existsSync(dir)) {
|
|
||||||
const err = new Error(
|
|
||||||
`ENOENT: no such file or directory, open '${String(p)}'`
|
|
||||||
);
|
|
||||||
err.code = 'ENOENT';
|
|
||||||
err.path = String(p);
|
|
||||||
err.syscall = 'open';
|
|
||||||
throw err;
|
|
||||||
}
|
}
|
||||||
|
let image = params.image;
|
||||||
return diskPath;
|
if (typeof image === 'string' && !image.trimStart().startsWith('<')) {
|
||||||
};
|
image = vfs.readFileSync(image);
|
||||||
|
}
|
||||||
// Patch `toFile` on sharp's PROTOTYPE, not on individual instances.
|
const res = await optimizeImage({ ...params, image });
|
||||||
//
|
return {
|
||||||
// Snippets chain (`sharp(...).png().resize(...).toFile(...)`) and every chained call
|
...res,
|
||||||
// returns a NEW instance, so patching one instance only catches a direct
|
data: Buffer.from(res.data),
|
||||||
// `sharp(...).toFile(...)`. Patching the shared prototype catches every derived instance.
|
|
||||||
const proto = sharp.prototype;
|
|
||||||
if (proto && typeof proto.toFile === 'function' && !proto.toFile.__vfsPatched) {
|
|
||||||
const originalToFile = proto.toFile;
|
|
||||||
const patched = function toFile(file, ...rest) {
|
|
||||||
return originalToFile.call(this, resolveOut(file), ...rest);
|
|
||||||
};
|
};
|
||||||
patched.__vfsPatched = true;
|
|
||||||
proto.toFile = patched;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Factory passthrough preserving statics + prototype.
|
|
||||||
const wrapped = function sharpFactory(...args) {
|
|
||||||
return sharp(...args);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Preserve statics (sharp.cache, sharp.concurrency, sharp.simd, ...) and prototype.
|
|
||||||
Object.setPrototypeOf(wrapped, sharp);
|
|
||||||
for (const key of Object.keys(sharp)) {
|
|
||||||
try { wrapped[key] = sharp[key]; } catch { /* read-only static */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
return wrapped;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExcelJS reads and writes files via Node's `fs` when using `.readFile()` / `.writeFile()` / streaming.
|
// ExcelJS reads and writes files via Node's `fs` when using `.readFile()` / `.writeFile()` / streaming.
|
||||||
@@ -600,7 +558,7 @@ async function runJob(job) {
|
|||||||
sandbox: {
|
sandbox: {
|
||||||
console: sandboxConsole,
|
console: sandboxConsole,
|
||||||
fs: vfs,
|
fs: vfs,
|
||||||
sharp: bridgeSharp(vfs),
|
optimizeImage: bridgeOptimizeImage(vfs),
|
||||||
ExcelJS: bridgedExcel,
|
ExcelJS: bridgedExcel,
|
||||||
exceljs: bridgedExcel,
|
exceljs: bridgedExcel,
|
||||||
PDFDocument: bridgedPDF,
|
PDFDocument: bridgedPDF,
|
||||||
@@ -675,7 +633,7 @@ async function runJob(job) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pooled job loop. Imports (sharp, exceljs, pdfkit, libdashstore, …) are paid ONCE for the
|
// Pooled job loop. Imports (wasm-image-optimization, exceljs, pdfkit, libdashstore, …) are paid ONCE for the
|
||||||
// lifetime of the thread; each subsequent job only builds a fresh NodeVM and runs the snippet,
|
// lifetime of the thread; each subsequent job only builds a fresh NodeVM and runs the snippet,
|
||||||
// which is the ~2 ms part. Jobs are serialized (one `await` per message) so a pooled thread
|
// which is the ~2 ms part. Jobs are serialized (one `await` per message) so a pooled thread
|
||||||
// never interleaves two applets against the same VFS.
|
// never interleaves two applets against the same VFS.
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export const TOOL_DESCRIPTIONS = {
|
|||||||
javascript_execute:
|
javascript_execute:
|
||||||
'Execute JS in an isolated sandbox via inline `code` (assign to `module.exports`) or `appletvfsfile` ' +
|
'Execute JS in an isolated sandbox via inline `code` (assign to `module.exports`) or `appletvfsfile` ' +
|
||||||
'(.applet.js returning typed CSV per @applet-input-schema, @applet-output-schema, and @applet-description; details in applet.SKILL). ' +
|
'(.applet.js returning typed CSV per @applet-input-schema, @applet-output-schema, and @applet-description; details in applet.SKILL). ' +
|
||||||
'Pre-injected globals: fs (use only root-relative paths for all file operations, absolute paths will fail), sharp, ExcelJS, PDFDocument, crypto, and fetch("/path"). No require/import.',
|
'Pre-injected globals: fs (use only root-relative paths for all file operations, absolute paths will fail), optimizeImage, ExcelJS, PDFDocument, crypto, and fetch("/path"). No require/import.',
|
||||||
|
|
||||||
find_files:
|
find_files:
|
||||||
'List VFS files (root-relative paths). Set skills=true to list root-level "<slug>.SKILL" files.',
|
'List VFS files (root-relative paths). Set skills=true to list root-level "<slug>.SKILL" files.',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import sharp from 'sharp';
|
import { optimizeImage } from 'wasm-image-optimization';
|
||||||
import { defineTool, ToolError } from 'libllmorbiter';
|
import { defineTool, ToolError } from 'libllmorbiter';
|
||||||
import * as dbCompanySetting from '../db/companysetting.js';
|
import * as dbCompanySetting from '../db/companysetting.js';
|
||||||
import { prepareVfs, syncVfsToDb } from './vfs.js';
|
import { prepareVfs, syncVfsToDb } from './vfs.js';
|
||||||
@@ -118,11 +118,20 @@ async function toAvif(img, { signal } = {}) {
|
|||||||
const input = Buffer.from(await res.arrayBuffer());
|
const input = Buffer.from(await res.arrayBuffer());
|
||||||
if (input.length === 0) continue;
|
if (input.length === 0) continue;
|
||||||
|
|
||||||
return await sharp(input)
|
const meta = await optimizeImage({ image: input, format: 'none' });
|
||||||
.rotate() // honour EXIF orientation before resizing
|
const srcW = meta.originalWidth || 0;
|
||||||
.resize(MAX_EDGE, MAX_EDGE, { fit: 'inside', withoutEnlargement: true })
|
const srcH = meta.originalHeight || 0;
|
||||||
.avif({ quality: AVIF_QUALITY })
|
const needsResize = srcW > MAX_EDGE || srcH > MAX_EDGE;
|
||||||
.toBuffer();
|
const scale = needsResize ? Math.min(MAX_EDGE / srcW, MAX_EDGE / srcH) : 1;
|
||||||
|
const out = await optimizeImage({
|
||||||
|
image: input,
|
||||||
|
width: needsResize ? Math.max(1, Math.round(srcW * scale)) : undefined,
|
||||||
|
height: needsResize ? Math.max(1, Math.round(srcH * scale)) : undefined,
|
||||||
|
fit: 'fill',
|
||||||
|
format: 'avif',
|
||||||
|
quality: AVIF_QUALITY,
|
||||||
|
});
|
||||||
|
return Buffer.from(out.data);
|
||||||
} catch {
|
} catch {
|
||||||
// Try the next candidate URL; a dead imageUrl with a live thumbnail is common.
|
// Try the next candidate URL; a dead imageUrl with a live thumbnail is common.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import sharp from 'sharp';
|
import { optimizeImage } from 'wasm-image-optimization';
|
||||||
import * as db from '../../db/posterminal.js';
|
import * as db from '../../db/posterminal.js';
|
||||||
|
|
||||||
const PIMAGE_DEFAULT_W = 200;
|
const PIMAGE_DEFAULT_W = 200;
|
||||||
@@ -250,42 +250,63 @@ async function servePosImage(req, res, { isCimage = false } = {}) {
|
|||||||
return res.status(304).end();
|
return res.status(304).end();
|
||||||
}
|
}
|
||||||
|
|
||||||
const imageSharp = sharp(image.buffer);
|
const meta = await optimizeImage({ image: image.buffer, format: 'none' });
|
||||||
|
const srcW = meta.originalWidth || targetW;
|
||||||
|
const srcH = meta.originalHeight || targetH;
|
||||||
|
const scale = Math.min(1, targetW / srcW, targetH / srcH);
|
||||||
|
const scaledW = Math.max(1, Math.round(srcW * scale));
|
||||||
|
const scaledH = Math.max(1, Math.round(srcH * scale));
|
||||||
|
|
||||||
const resizeOptions = isCimage
|
const scaledPng = await optimizeImage({
|
||||||
? {
|
image: image.buffer,
|
||||||
fit: 'contain',
|
width: scale < 1 ? scaledW : undefined,
|
||||||
background: '#ffffff',
|
height: scale < 1 ? scaledH : undefined,
|
||||||
withoutEnlargement: true,
|
fit: 'fill',
|
||||||
}
|
format: 'png',
|
||||||
: {
|
});
|
||||||
fit: 'inside',
|
|
||||||
withoutEnlargement: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
let pipeline = imageSharp
|
const canvasW = isCimage ? targetW : scaledPng.width;
|
||||||
.resize(targetW, targetH, resizeOptions)
|
const canvasH = isCimage ? targetH : scaledPng.height;
|
||||||
.flatten({ background: '#ffffff' });
|
const offsetX = Math.round((canvasW - scaledPng.width) / 2);
|
||||||
|
const offsetY = Math.round((canvasH - scaledPng.height) / 2);
|
||||||
|
const b64 = Buffer.from(scaledPng.data).toString('base64');
|
||||||
|
const flatSvg =
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" width="${canvasW}" height="${canvasH}">` +
|
||||||
|
`<rect width="${canvasW}" height="${canvasH}" fill="#ffffff"/>` +
|
||||||
|
`<image href="data:image/png;base64,${b64}" x="${offsetX}" y="${offsetY}" width="${scaledPng.width}" height="${scaledPng.height}"/>` +
|
||||||
|
`</svg>`;
|
||||||
|
|
||||||
const accept = String(req.headers.accept || '');
|
const accept = String(req.headers.accept || '');
|
||||||
const formatParam = typeof req.query.format === 'string' ? req.query.format.toLowerCase() : '';
|
const formatParam = typeof req.query.format === 'string' ? req.query.format.toLowerCase() : '';
|
||||||
|
|
||||||
let contentType = 'image/jpeg';
|
let contentType = 'image/jpeg';
|
||||||
|
let targetFormat = 'jpeg';
|
||||||
|
let quality = 85;
|
||||||
|
let speed = 6;
|
||||||
if (formatParam === 'avif' || (!formatParam && accept.includes('image/avif'))) {
|
if (formatParam === 'avif' || (!formatParam && accept.includes('image/avif'))) {
|
||||||
pipeline = pipeline.avif({ effort: 2 });
|
targetFormat = 'avif';
|
||||||
|
speed = 8;
|
||||||
contentType = 'image/avif';
|
contentType = 'image/avif';
|
||||||
} else if (formatParam === 'webp' || (!formatParam && accept.includes('image/webp'))) {
|
} else if (formatParam === 'webp' || (!formatParam && accept.includes('image/webp'))) {
|
||||||
pipeline = pipeline.webp({ quality: 85 });
|
targetFormat = 'webp';
|
||||||
|
quality = 85;
|
||||||
contentType = 'image/webp';
|
contentType = 'image/webp';
|
||||||
} else if (formatParam === 'png') {
|
} else if (formatParam === 'png') {
|
||||||
pipeline = pipeline.png();
|
targetFormat = 'png';
|
||||||
contentType = 'image/png';
|
contentType = 'image/png';
|
||||||
} else {
|
} else {
|
||||||
pipeline = pipeline.jpeg({ quality: 85 });
|
targetFormat = 'jpeg';
|
||||||
|
quality = 85;
|
||||||
contentType = 'image/jpeg';
|
contentType = 'image/jpeg';
|
||||||
}
|
}
|
||||||
|
|
||||||
const buffer = await pipeline.toBuffer();
|
const encoded = await optimizeImage({
|
||||||
|
image: flatSvg,
|
||||||
|
format: targetFormat,
|
||||||
|
quality,
|
||||||
|
speed,
|
||||||
|
});
|
||||||
|
const buffer = Buffer.from(encoded.data);
|
||||||
|
|
||||||
res.setHeader('Content-Type', contentType);
|
res.setHeader('Content-Type', contentType);
|
||||||
res.setHeader('Content-Length', String(buffer.length));
|
res.setHeader('Content-Length', String(buffer.length));
|
||||||
|
|||||||
Reference in New Issue
Block a user