sharp by wasm
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import sharp from 'sharp';
|
||||
import { optimizeImage } from 'wasm-image-optimization';
|
||||
import { encodeRGBA } from 'skelehash-wasm';
|
||||
import { DELETE_BY_ARTICLE_AND_HASH as MSSQL_QUERY } from '../../providers/mssql/queries/pic.js';
|
||||
import { DELETE_BY_ARTICLE_AND_HASH as SQLITE_QUERY } from '../../providers/sqlite3/queries/pic.js';
|
||||
@@ -49,8 +49,8 @@ export default function deleteImage(provider) {
|
||||
|
||||
let skelehash = null;
|
||||
if (thumbBuffer) {
|
||||
const { data, info } = await sharp(thumbBuffer).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
||||
const res = await encodeRGBA(info.width, info.height, data);
|
||||
const raw = await optimizeImage({ image: thumbBuffer, format: 'raw' });
|
||||
const res = await encodeRGBA(raw.width, raw.height, raw.data);
|
||||
skelehash = res?.base62;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import resolveCategoryAccess from './resolveCategoryAccess.js';
|
||||
import addImage from './addImage.js';
|
||||
import makeRunner from '../runner.js';
|
||||
import sharp from 'sharp';
|
||||
import { optimizeImage } from 'wasm-image-optimization';
|
||||
|
||||
export default function generateImage(provider) {
|
||||
const access = resolveCategoryAccess(provider);
|
||||
@@ -148,32 +148,32 @@ export default function generateImage(provider) {
|
||||
const TOTAL_SIZE = 1200;
|
||||
const tileSize = TOTAL_SIZE / targetSize;
|
||||
|
||||
const composites = await Promise.all(
|
||||
const tiles = await Promise.all(
|
||||
finalPics.map(async (pic, idx) => {
|
||||
const row = Math.floor(idx / targetSize);
|
||||
const col = idx % targetSize;
|
||||
const tileBuffer = await sharp(pic.buffer)
|
||||
.resize(tileSize, tileSize, { fit: 'cover', position: 'center' })
|
||||
.toBuffer();
|
||||
return {
|
||||
input: tileBuffer,
|
||||
left: col * tileSize,
|
||||
top: row * tileSize,
|
||||
};
|
||||
const tile = await optimizeImage({
|
||||
image: pic.buffer,
|
||||
width: tileSize,
|
||||
height: tileSize,
|
||||
fit: 'cover',
|
||||
format: 'png',
|
||||
});
|
||||
const b64 = Buffer.from(tile.data).toString('base64');
|
||||
return `<image href="data:image/png;base64,${b64}" x="${col * tileSize}" y="${row * tileSize}" width="${tileSize}" height="${tileSize}"/>`;
|
||||
}),
|
||||
);
|
||||
|
||||
const mosaicBuffer = await sharp({
|
||||
create: {
|
||||
width: TOTAL_SIZE,
|
||||
height: TOTAL_SIZE,
|
||||
channels: 4,
|
||||
background: { r: 255, g: 255, b: 255, alpha: 1 },
|
||||
},
|
||||
})
|
||||
.composite(composites)
|
||||
.png()
|
||||
.toBuffer();
|
||||
const mosaicSvg =
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${TOTAL_SIZE}" height="${TOTAL_SIZE}">` +
|
||||
`<rect width="${TOTAL_SIZE}" height="${TOTAL_SIZE}" fill="#ffffff"/>` +
|
||||
tiles.join('') +
|
||||
`</svg>`;
|
||||
const mosaicRes = await optimizeImage({
|
||||
image: mosaicSvg,
|
||||
format: 'png',
|
||||
});
|
||||
const mosaicBuffer = Buffer.from(mosaicRes.data);
|
||||
|
||||
// Clear existing categorypic rows so the new mosaic becomes the main (first) image
|
||||
await runner.query('DELETE FROM categorypic WHERE category_id = @categoryId', { categoryId });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import sharp from 'sharp';
|
||||
import { optimizeImage } from 'wasm-image-optimization';
|
||||
import generateDocumentThumb from '../../util/generateDocumentThumb.js';
|
||||
import * as mssqlQueries from '../../providers/mssql/queries/manifestattachment.js';
|
||||
import * as sqliteQueries from '../../providers/sqlite3/queries/manifestattachment.js';
|
||||
@@ -118,9 +118,9 @@ export default function add(provider) {
|
||||
|
||||
if (thumbavif && (!thumbx || !thumby)) {
|
||||
try {
|
||||
const meta = await sharp(thumbavif).metadata();
|
||||
thumbx = thumbx ?? meta.width ?? null;
|
||||
thumby = thumby ?? meta.height ?? null;
|
||||
const meta = await optimizeImage({ image: thumbavif, format: 'none' });
|
||||
thumbx = thumbx ?? meta.originalWidth ?? null;
|
||||
thumby = thumby ?? meta.originalHeight ?? null;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import sharp from 'sharp';
|
||||
import { optimizeImage } from 'wasm-image-optimization';
|
||||
import { pdfToPng } from 'pdfconvert-wasm';
|
||||
|
||||
const THUMB_MAX = 200;
|
||||
@@ -97,18 +97,27 @@ export default async function generateDocumentThumb(content, mimetype, options =
|
||||
}
|
||||
|
||||
try {
|
||||
const thumbResult = await sharp(imageBuffer)
|
||||
.resize(THUMB_MAX, THUMB_MAX, { fit: 'inside', withoutEnlargement: true })
|
||||
.avif({ effort: 4 })
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
const meta = await optimizeImage({ image: imageBuffer, format: 'none' });
|
||||
const srcW = meta.originalWidth || 0;
|
||||
const srcH = meta.originalHeight || 0;
|
||||
const needsResize = srcW > THUMB_MAX || srcH > THUMB_MAX;
|
||||
const scale = needsResize ? Math.min(THUMB_MAX / srcW, THUMB_MAX / srcH) : 1;
|
||||
const thumbResult = await optimizeImage({
|
||||
image: imageBuffer,
|
||||
width: needsResize ? Math.max(1, Math.round(srcW * scale)) : undefined,
|
||||
height: needsResize ? Math.max(1, Math.round(srcH * scale)) : undefined,
|
||||
fit: 'fill',
|
||||
format: 'avif',
|
||||
speed: 6,
|
||||
});
|
||||
|
||||
return {
|
||||
thumbavif: thumbResult.data,
|
||||
thumbx: thumbResult.info.width,
|
||||
thumby: thumbResult.info.height,
|
||||
thumbavif: Buffer.from(thumbResult.data),
|
||||
thumbx: thumbResult.width,
|
||||
thumby: thumbResult.height,
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn('generateDocumentThumb: sharp thumbnail generation failed:', err.message);
|
||||
console.warn('generateDocumentThumb: thumbnail generation failed:', err.message);
|
||||
return { thumbavif: null, thumbx: null, thumby: null };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import sharp from 'sharp';
|
||||
import { optimizeImage } from 'wasm-image-optimization';
|
||||
import { rgbaToThumbHash } from 'thumbhash';
|
||||
import { encodeRGBA } from 'skelehash-wasm';
|
||||
|
||||
@@ -6,63 +6,105 @@ const FULL_MAX = 1000;
|
||||
const THUMB_MAX = 200;
|
||||
const SMALL_THUMB_MAX = 32;
|
||||
|
||||
function fitInsideDims(srcW, srcH, maxW, maxH) {
|
||||
if (!srcW || !srcH || (srcW <= maxW && srcH <= maxH)) {
|
||||
return {};
|
||||
}
|
||||
const scale = Math.min(maxW / srcW, maxH / srcH);
|
||||
return {
|
||||
width: Math.max(1, Math.round(srcW * scale)),
|
||||
height: Math.max(1, Math.round(srcH * scale)),
|
||||
fit: 'fill',
|
||||
};
|
||||
}
|
||||
|
||||
function unpremultiplyRgba(data) {
|
||||
const out = new Uint8Array(data.length);
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const a = data[i + 3];
|
||||
if (a === 255 || a === 0) {
|
||||
out[i] = data[i];
|
||||
out[i + 1] = data[i + 1];
|
||||
out[i + 2] = data[i + 2];
|
||||
out[i + 3] = a;
|
||||
} else {
|
||||
const f = 255 / a;
|
||||
out[i] = Math.min(255, Math.round(data[i] * f));
|
||||
out[i + 1] = Math.min(255, Math.round(data[i + 1] * f));
|
||||
out[i + 2] = Math.min(255, Math.round(data[i + 2] * f));
|
||||
out[i + 3] = a;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export default async function processAvifImage(inputBuffer) {
|
||||
const input = Buffer.isBuffer(inputBuffer)
|
||||
? inputBuffer
|
||||
: Buffer.from(inputBuffer);
|
||||
|
||||
const meta = await sharp(input).metadata();
|
||||
const w = meta.width ?? 0;
|
||||
const h = meta.height ?? 0;
|
||||
const isAvif =
|
||||
meta.format === 'avif' ||
|
||||
(meta.format === 'heif' && meta.compression === 'av1');
|
||||
const meta = await optimizeImage({ image: input, format: 'none' });
|
||||
const w = meta.originalWidth ?? 0;
|
||||
const h = meta.originalHeight ?? 0;
|
||||
const isAvif = meta.originalFormat === 'avif';
|
||||
const useOriginalFull =
|
||||
isAvif && w > 0 && h > 0 && w <= FULL_MAX && h <= FULL_MAX;
|
||||
|
||||
if (!useOriginalFull) {
|
||||
console.log(
|
||||
`processAvifImage: converting full image (format=${meta.format ?? 'unknown'}, ${w}×${h}, avif=${isAvif})`,
|
||||
`processAvifImage: converting full image (format=${meta.originalFormat ?? 'unknown'}, ${w}×${h}, avif=${isAvif})`,
|
||||
);
|
||||
}
|
||||
|
||||
const thumbPipeline = sharp(input)
|
||||
.resize(THUMB_MAX, THUMB_MAX, { fit: 'inside', withoutEnlargement: true });
|
||||
const smallThumbPipeline = sharp(input)
|
||||
.resize(SMALL_THUMB_MAX, SMALL_THUMB_MAX, { fit: 'inside', withoutEnlargement: true });
|
||||
const fullDims = fitInsideDims(w, h, FULL_MAX, FULL_MAX);
|
||||
const thumbDims = fitInsideDims(w, h, THUMB_MAX, THUMB_MAX);
|
||||
const rgbaDims = fitInsideDims(w, h, 100, 100);
|
||||
const smallThumbDims = fitInsideDims(w, h, SMALL_THUMB_MAX, SMALL_THUMB_MAX);
|
||||
|
||||
const [full, thumb, thumbRgba, smallThumb] = await Promise.all([
|
||||
useOriginalFull
|
||||
? Promise.resolve({
|
||||
info: { width: w, height: h },
|
||||
width: w,
|
||||
height: h,
|
||||
data: input,
|
||||
})
|
||||
: sharp(input)
|
||||
.resize(FULL_MAX, FULL_MAX, { fit: 'inside', withoutEnlargement: true })
|
||||
.avif({ effort: 2 })
|
||||
.toBuffer({ resolveWithObject: true }),
|
||||
thumbPipeline.clone().avif({ effort: 2 }).toBuffer({ resolveWithObject: true }),
|
||||
thumbPipeline
|
||||
.clone()
|
||||
.resize(100, 100, { fit: 'inside', withoutEnlargement: true })
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true }),
|
||||
smallThumbPipeline
|
||||
.avif({ effort: 2 })
|
||||
.toBuffer({ resolveWithObject: true }),
|
||||
: optimizeImage({
|
||||
image: input,
|
||||
...fullDims,
|
||||
format: 'avif',
|
||||
speed: 8,
|
||||
}),
|
||||
optimizeImage({
|
||||
image: input,
|
||||
...thumbDims,
|
||||
format: 'avif',
|
||||
speed: 8,
|
||||
}),
|
||||
optimizeImage({
|
||||
image: input,
|
||||
...rgbaDims,
|
||||
format: 'raw',
|
||||
}),
|
||||
optimizeImage({
|
||||
image: input,
|
||||
...smallThumbDims,
|
||||
format: 'avif',
|
||||
speed: 8,
|
||||
}),
|
||||
]);
|
||||
|
||||
const rgbaPixels = unpremultiplyRgba(thumbRgba.data);
|
||||
|
||||
const [thumbHashBytes, skele] = await Promise.all([
|
||||
rgbaToThumbHash(
|
||||
thumbRgba.info.width,
|
||||
thumbRgba.info.height,
|
||||
thumbRgba.data,
|
||||
thumbRgba.width,
|
||||
thumbRgba.height,
|
||||
rgbaPixels,
|
||||
),
|
||||
encodeRGBA(
|
||||
thumbRgba.info.width,
|
||||
thumbRgba.info.height,
|
||||
thumbRgba.data,
|
||||
thumbRgba.width,
|
||||
thumbRgba.height,
|
||||
rgbaPixels,
|
||||
),
|
||||
]);
|
||||
|
||||
@@ -70,24 +112,28 @@ export default async function processAvifImage(inputBuffer) {
|
||||
throw new Error('encodeRGBA failed to produce a base62 skelehash');
|
||||
}
|
||||
|
||||
const fullBuf = Buffer.isBuffer(full.data) ? full.data : Buffer.from(full.data);
|
||||
const thumbBuf = Buffer.from(thumb.data);
|
||||
const smallThumbBuf = Buffer.from(smallThumb.data);
|
||||
|
||||
return {
|
||||
full: {
|
||||
w: full.info.width,
|
||||
h: full.info.height,
|
||||
size: full.data.length,
|
||||
buffer: full.data,
|
||||
w: full.width,
|
||||
h: full.height,
|
||||
size: fullBuf.length,
|
||||
buffer: fullBuf,
|
||||
},
|
||||
thumb: {
|
||||
w: thumb.info.width,
|
||||
h: thumb.info.height,
|
||||
size: thumb.data.length,
|
||||
buffer: thumb.data,
|
||||
w: thumb.width,
|
||||
h: thumb.height,
|
||||
size: thumbBuf.length,
|
||||
buffer: thumbBuf,
|
||||
hash: Buffer.from(thumbHashBytes).toString('base64'),
|
||||
},
|
||||
smallThumb: {
|
||||
w: smallThumb.info.width,
|
||||
h: smallThumb.info.height,
|
||||
buffer: smallThumb.data,
|
||||
w: smallThumb.width,
|
||||
h: smallThumb.height,
|
||||
buffer: smallThumbBuf,
|
||||
},
|
||||
skelehash: skele.base62,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user