sharp by wasm

This commit is contained in:
seb
2026-10-28 01:46:51 +01:00
parent 1c5385061c
commit e7884c03ef
20 changed files with 530 additions and 364 deletions

View File

@@ -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;

View File

@@ -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(
/(<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 {
buffer: composite,
buffer: Buffer.from(composite.data),
contentType: `image/${THUMB_FORMAT}`,
};
}

View File

@@ -84,7 +84,7 @@ export function register(app, basePath = '') {
* 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