sharp by wasm
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
// MARK: - Imports
|
||||
// #region Imports
|
||||
import sharp from 'sharp';
|
||||
import { optimizeImage } from 'wasm-image-optimization';
|
||||
import { encodeRGBA } from 'skelehash-wasm';
|
||||
import { createLogger } from '../../util/logger.js';
|
||||
// #endregion
|
||||
@@ -23,21 +23,22 @@ export async function computeSkeleHash(buffer) {
|
||||
const base64Data = input.includes(',') ? input.split(',')[1] : input;
|
||||
input = Buffer.from(base64Data, 'base64');
|
||||
}
|
||||
const { data, info } = await sharp(input)
|
||||
.rotate()
|
||||
.resize({ width: 200, height: 200, fit: 'inside' })
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
const raw = await optimizeImage({
|
||||
image: input,
|
||||
width: 200,
|
||||
height: 200,
|
||||
fit: 'contain',
|
||||
format: 'raw',
|
||||
});
|
||||
|
||||
const result = await encodeRGBA(info.width, info.height, data);
|
||||
const result = await encodeRGBA(raw.width, raw.height, raw.data);
|
||||
if (!result?.base62) {
|
||||
throw new Error('encodeRGBA failed to produce a base62 skelehash');
|
||||
}
|
||||
return {
|
||||
base62: result.base62,
|
||||
width: info.width,
|
||||
height: info.height,
|
||||
width: raw.width,
|
||||
height: raw.height,
|
||||
hashLength: result.hash?.length ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,18 +5,14 @@ import os from 'os';
|
||||
const FETCH_TIMEOUT_MS = 15000;
|
||||
const MAX_BYTES = 50 * 1024 * 1024; // 50MB
|
||||
|
||||
// The crop/trim/resize/AVIF pipeline runs up to six sequential sharp passes (PNG
|
||||
// intermediate, trim, extract, resize, AVIF encode). A sharp pipeline is owned by
|
||||
// whichever thread created it, so running it on the main thread stalls every HTTP
|
||||
// handler and every SSE client (heartbeats included). The pipeline therefore lives
|
||||
// in imageCropWorker.js and is reached through the small pool below.
|
||||
// The crop/trim/resize/AVIF pipeline runs in a worker thread so decoding,
|
||||
// trimming, and AVIF encoding never stall HTTP handlers or SSE heartbeats.
|
||||
// The pipeline lives in imageCropWorker.js and is reached through the pool below.
|
||||
const WORKER_PATH = fileURLToPath(new URL('./imageCropWorker.js', import.meta.url));
|
||||
const WORKER_BOOT_TIMEOUT_MS = 15000;
|
||||
const DEFAULT_JOB_TIMEOUT_MS = 30000;
|
||||
// libvips already saturates cores via its own per-process thread pool, so each worker
|
||||
// is mostly IO/decode-bound rather than needing one thread per core. Cap at 3 to stay
|
||||
// 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
|
||||
// Cap at 3 workers to stay 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.
|
||||
const CPU_COUNT = Math.max(1, os.cpus?.()?.length || os.availableParallelism?.() || 2);
|
||||
const POOL_SIZE = Math.max(1, Math.min(3, CPU_COUNT - 1));
|
||||
@@ -67,8 +63,8 @@ function spawnWorker() {
|
||||
}
|
||||
|
||||
function onBooted(msg) {
|
||||
// The worker sends an explicit `{ ready: true }` once its imports (notably
|
||||
// sharp/libvips) have resolved. Without this handshake there is no first
|
||||
// The worker sends an explicit `{ ready: true }` once its imports
|
||||
// have resolved. Without this handshake there is no first
|
||||
// message to await, so a worker that fails to boot would only surface later
|
||||
// as a confusing per-job failure.
|
||||
if (!msg || msg.ready !== true) return;
|
||||
@@ -158,7 +154,7 @@ function runJob(entry, inputBuffer, timeoutMs) {
|
||||
let settled = false;
|
||||
|
||||
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
|
||||
// its eventual reply would then arrive against an already-settled job.
|
||||
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,
|
||||
* and converts to AVIF.
|
||||
*
|
||||
* Runs the sharp 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
|
||||
* Runs the image pipeline in a pooled worker thread (see imageCropWorker.js) so the
|
||||
* 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
|
||||
* `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
|
||||
// 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();
|
||||
return enqueueJob(inputBuffer, timeout);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
'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,
|
||||
* extract, resize, AVIF encode) whose JS glue cost lands on the thread that owns
|
||||
* the pipeline. Running it here keeps that cost off the main event loop, which
|
||||
* otherwise stalls HTTP handlers and every SSE client (heartbeats included).
|
||||
* Running the decode, pixel-trim scan, crop/resize, and AVIF encode in a worker thread
|
||||
* keeps the cost off the main event loop, which otherwise stalls HTTP handlers and
|
||||
* every SSE client (heartbeats included).
|
||||
*
|
||||
* Protocol: the parent sends `{ id, buffer }` (an ArrayBuffer, transferred) and
|
||||
* the worker replies `{ id, ok: true, buffer, width, height, size }` (buffer
|
||||
@@ -14,13 +13,13 @@
|
||||
*/
|
||||
|
||||
import { parentPort } from 'worker_threads';
|
||||
import sharp from 'sharp';
|
||||
import { optimizeImage } from 'wasm-image-optimization';
|
||||
|
||||
const MAX_EDGE = 1000;
|
||||
const TRIM_THRESHOLD = 10;
|
||||
const RESTORE_MARGIN = 5; // px restored per edge after trimming (up to what was trimmed)
|
||||
const AVIF_QUALITY = 80;
|
||||
const AVIF_EFFORT = 4;
|
||||
const AVIF_SPEED = 6;
|
||||
|
||||
/**
|
||||
* 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 }>}
|
||||
*/
|
||||
async function cropAndConvertToAvif(inputBuffer) {
|
||||
// 1. Initial metadata and orientation correction
|
||||
const baseSharp = sharp(inputBuffer, { failOn: 'none' }).rotate();
|
||||
const initialMeta = await baseSharp.metadata();
|
||||
const hasAlpha = Boolean(initialMeta.hasAlpha);
|
||||
// 1. Decode to oriented raw RGBA pixels
|
||||
const raw = await optimizeImage({
|
||||
image: inputBuffer,
|
||||
format: 'raw',
|
||||
});
|
||||
const origW = raw.width;
|
||||
const origH = raw.height;
|
||||
const pixels = raw.data;
|
||||
|
||||
// Use PNG intermediate for trimming passes to preserve alpha
|
||||
const originalBuffer = await baseSharp.png().toBuffer();
|
||||
const origMeta = await sharp(originalBuffer).metadata();
|
||||
let hasAlpha = false;
|
||||
for (let i = 3; i < pixels.length; i += 4) {
|
||||
if (pixels[i] < 255) {
|
||||
hasAlpha = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Cumulative crop box in original (oriented) pixel coordinates
|
||||
let box = { left: 0, top: 0, width: origMeta.width, height: origMeta.height };
|
||||
let currentBuffer = originalBuffer;
|
||||
let box = { left: 0, top: 0, width: origW, height: origH };
|
||||
|
||||
const trimPass = async (background) => {
|
||||
try {
|
||||
const trimmed = await sharp(currentBuffer)
|
||||
.trim({ background, threshold: TRIM_THRESHOLD })
|
||||
.png()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
if (trimmed.info.width > 0 && trimmed.info.height > 0) {
|
||||
currentBuffer = trimmed.data;
|
||||
box = {
|
||||
left: box.left - (trimmed.info.trimOffsetLeft ?? 0),
|
||||
top: box.top - (trimmed.info.trimOffsetTop ?? 0),
|
||||
width: trimmed.info.width,
|
||||
height: trimmed.info.height,
|
||||
};
|
||||
const isTransparent = (idx) => pixels[idx + 3] <= TRIM_THRESHOLD;
|
||||
const isWhite = (idx) => {
|
||||
const a = pixels[idx + 3];
|
||||
if (a < 255 - TRIM_THRESHOLD) return false;
|
||||
return (
|
||||
pixels[idx] >= 255 - TRIM_THRESHOLD &&
|
||||
pixels[idx + 1] >= 255 - TRIM_THRESHOLD &&
|
||||
pixels[idx + 2] >= 255 - TRIM_THRESHOLD
|
||||
);
|
||||
};
|
||||
|
||||
const trimPass = (isBg) => {
|
||||
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 {
|
||||
// Keep currentBuffer
|
||||
if (!rowBg) break;
|
||||
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
|
||||
if (hasAlpha) await trimPass({ r: 0, g: 0, b: 0, alpha: 0 });
|
||||
if (hasAlpha) trimPass(isTransparent);
|
||||
|
||||
// 3. Step B: Trim white borders
|
||||
await trimPass('#ffffff');
|
||||
trimPass(isWhite);
|
||||
|
||||
// 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
|
||||
const left = Math.max(0, box.left - RESTORE_MARGIN);
|
||||
const top = Math.max(0, box.top - RESTORE_MARGIN);
|
||||
const right = Math.min(origMeta.width, box.left + box.width + RESTORE_MARGIN);
|
||||
const bottom = Math.min(origMeta.height, box.top + box.height + RESTORE_MARGIN);
|
||||
if (left !== box.left || top !== box.top || right - left !== box.width || bottom - top !== box.height) {
|
||||
try {
|
||||
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();
|
||||
const right = Math.min(origW, box.left + box.width + RESTORE_MARGIN);
|
||||
const bottom = Math.min(origH, box.top + box.height + RESTORE_MARGIN);
|
||||
const cropW = right - left;
|
||||
const cropH = bottom - top;
|
||||
|
||||
// 5. Resize to max 1000px without enlargement
|
||||
let finalPipeline = sharp(currentBuffer);
|
||||
if (currentMeta.width > MAX_EDGE || currentMeta.height > MAX_EDGE) {
|
||||
finalPipeline = finalPipeline.resize(MAX_EDGE, MAX_EDGE, {
|
||||
fit: 'inside',
|
||||
withoutEnlargement: true,
|
||||
});
|
||||
}
|
||||
// 5. Resize to max 1000px without enlargement and convert to AVIF
|
||||
const needsCrop = left > 0 || top > 0 || cropW < origW || cropH < origH;
|
||||
const needsResize = cropW > MAX_EDGE || cropH > MAX_EDGE;
|
||||
const scale = needsResize ? Math.min(MAX_EDGE / cropW, MAX_EDGE / cropH) : 1;
|
||||
|
||||
// 6. Convert to AVIF
|
||||
const avifResult = await finalPipeline
|
||||
.avif({ quality: AVIF_QUALITY, effort: AVIF_EFFORT })
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
const avifResult = await optimizeImage({
|
||||
image: inputBuffer,
|
||||
crop: needsCrop ? { x: left, y: top, width: cropW, height: cropH } : undefined,
|
||||
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 {
|
||||
buffer: avifResult.data,
|
||||
width: avifResult.info.width,
|
||||
height: avifResult.info.height,
|
||||
size: avifResult.info.size,
|
||||
buffer: outBuffer,
|
||||
width: avifResult.width,
|
||||
height: avifResult.height,
|
||||
size: outBuffer.length,
|
||||
};
|
||||
}
|
||||
|
||||
// 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.
|
||||
parentPort.postMessage({ ready: true });
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Worker } from 'worker_threads';
|
||||
import { fileURLToPath } from 'url';
|
||||
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
|
||||
// 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.
|
||||
@@ -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.
|
||||
const MAX_WORKERS_PER_COMPANY = 2;
|
||||
// ...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 DEFAULT_BUFFER_LIMIT = 32 * 1024 * 1024;
|
||||
@@ -228,7 +228,7 @@ function runJobOnWorker(entry, job, timeoutMs) {
|
||||
let settled = false;
|
||||
|
||||
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
|
||||
// eventual reply would arrive against an already-settled job.
|
||||
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';
|
||||
|
||||
const THUMB_W = 150;
|
||||
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.
|
||||
*
|
||||
@@ -32,17 +82,35 @@ export async function renderThumb({ width, height, objects }) {
|
||||
|
||||
// 2. Resize to fit within 150×80, preserving aspect ratio.
|
||||
// Transparent background for letterbox padding.
|
||||
const { data, info } = await sharp(nativePng)
|
||||
.ensureAlpha()
|
||||
.resize(THUMB_W, THUMB_H, {
|
||||
fit: 'contain',
|
||||
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
||||
kernel: sharp.kernel.lanczos3,
|
||||
})
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
const scaled = await optimizeImage({
|
||||
image: nativePng,
|
||||
width: THUMB_W,
|
||||
height: THUMB_H,
|
||||
fit: 'contain',
|
||||
format: 'raw',
|
||||
});
|
||||
|
||||
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),
|
||||
// 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).
|
||||
const thumb = await sharp(rgba, { raw: { width: w, height: h, channels: 4 } })
|
||||
.png({ palette: true, colours: 17, compressionLevel: 9 })
|
||||
.toBuffer();
|
||||
// 4. Encode as PNG via wasm-image-optimization.
|
||||
const rawPng = encodeRgbaToPng(w, h, rgba);
|
||||
const thumb = await optimizeImage({
|
||||
image: rawPng,
|
||||
format: 'png',
|
||||
});
|
||||
|
||||
return thumb;
|
||||
return Buffer.from(thumb.data);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import PDFDocument from 'pdfkit';
|
||||
import { createCanvas } from 'canvas';
|
||||
import JsBarcode from 'jsbarcode';
|
||||
import sharp from 'sharp';
|
||||
import { optimizeImage } from 'wasm-image-optimization';
|
||||
import { getStore } from '../db/store.js';
|
||||
import * as dbOrder from '../db/order.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) {
|
||||
if (smallthumbbinary) {
|
||||
try {
|
||||
return await sharp(smallthumbbinary).png().toBuffer();
|
||||
const res = await optimizeImage({ image: smallthumbbinary, format: 'png' });
|
||||
return Buffer.from(res.data);
|
||||
} catch { /* fallthrough */ }
|
||||
}
|
||||
if (picHash) {
|
||||
try {
|
||||
const thumb = await store.pic.getThumbByHash(googleAuthId, picHash);
|
||||
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 */ }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user