sharp by wasm
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user