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,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);
}