8f8eef53c9
- Optimize imported scene preview images (smart WebP/JPEG/PNG, preserve alpha, keep pixel size) - Update converter to re-encode existing image assets with same algorithm - Improve import/export progress overlay and reduce presentation slide stutter Made-with: Cursor
219 lines
5.4 KiB
JavaScript
219 lines
5.4 KiB
JavaScript
/**
|
|
* Visually lossless re-encode for imported raster images (same pixel dimensions).
|
|
* Node-only; shared by the main app and tools/project-converter.
|
|
*/
|
|
import sharp from 'sharp';
|
|
|
|
/** @typedef {import('node:buffer').Buffer} Buffer */
|
|
|
|
/**
|
|
* @typedef {Object} OptimizeImageImportResult
|
|
* @property {Buffer} buffer
|
|
* @property {string} mime
|
|
* @property {string} ext filename extension without dot (e.g. webp, jpg, png)
|
|
* @property {number} width
|
|
* @property {number} height
|
|
* @property {boolean} passthrough true if original bytes were kept
|
|
*/
|
|
|
|
const WEBP_EFFORT = 6;
|
|
const RASTER_QUALITY = 95;
|
|
|
|
/**
|
|
* @param {Buffer} buf
|
|
* @param {import('sharp').Metadata | null} meta
|
|
* @returns {OptimizeImageImportResult}
|
|
*/
|
|
function makePassthrough(buf, meta) {
|
|
const fmt = meta?.format;
|
|
if (fmt === 'jpeg' || fmt === 'jpg') {
|
|
return {
|
|
buffer: buf,
|
|
mime: 'image/jpeg',
|
|
ext: 'jpg',
|
|
width: meta?.width ?? 0,
|
|
height: meta?.height ?? 0,
|
|
passthrough: true,
|
|
};
|
|
}
|
|
if (fmt === 'png') {
|
|
return {
|
|
buffer: buf,
|
|
mime: 'image/png',
|
|
ext: 'png',
|
|
width: meta?.width ?? 0,
|
|
height: meta?.height ?? 0,
|
|
passthrough: true,
|
|
};
|
|
}
|
|
if (fmt === 'webp') {
|
|
return {
|
|
buffer: buf,
|
|
mime: 'image/webp',
|
|
ext: 'webp',
|
|
width: meta?.width ?? 0,
|
|
height: meta?.height ?? 0,
|
|
passthrough: true,
|
|
};
|
|
}
|
|
if (fmt === 'gif') {
|
|
return {
|
|
buffer: buf,
|
|
mime: 'image/gif',
|
|
ext: 'gif',
|
|
width: meta?.width ?? 0,
|
|
height: meta?.height ?? 0,
|
|
passthrough: true,
|
|
};
|
|
}
|
|
if (fmt === 'tiff' || fmt === 'tif') {
|
|
return {
|
|
buffer: buf,
|
|
mime: 'image/tiff',
|
|
ext: 'tiff',
|
|
width: meta?.width ?? 0,
|
|
height: meta?.height ?? 0,
|
|
passthrough: true,
|
|
};
|
|
}
|
|
if (fmt === 'bmp') {
|
|
return {
|
|
buffer: buf,
|
|
mime: 'image/bmp',
|
|
ext: 'bmp',
|
|
width: meta?.width ?? 0,
|
|
height: meta?.height ?? 0,
|
|
passthrough: true,
|
|
};
|
|
}
|
|
return {
|
|
buffer: buf,
|
|
mime: 'application/octet-stream',
|
|
ext: 'bin',
|
|
width: meta?.width ?? 0,
|
|
height: meta?.height ?? 0,
|
|
passthrough: true,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param {Buffer} outBuf
|
|
* @param {number} w0
|
|
* @param {number} h0
|
|
*/
|
|
async function sameDimensionsOrThrow(outBuf, w0, h0) {
|
|
const m = await sharp(outBuf).metadata();
|
|
if ((m.width ?? 0) !== w0 || (m.height ?? 0) !== h0) {
|
|
const err = new Error('encode changed dimensions');
|
|
err.code = 'DIM';
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {Buffer} src
|
|
* @returns {Promise<OptimizeImageImportResult>}
|
|
*/
|
|
export async function optimizeImageBufferVisuallyLossless(src) {
|
|
const input = Buffer.isBuffer(src) ? src : Buffer.from(src);
|
|
if (input.length === 0) {
|
|
return makePassthrough(input, { width: 0, height: 0, format: 'png' });
|
|
}
|
|
|
|
let meta0;
|
|
try {
|
|
meta0 = await sharp(input, { failOn: 'error', unlimited: true }).metadata();
|
|
} catch {
|
|
return makePassthrough(input, null);
|
|
}
|
|
|
|
const width = meta0.width ?? 0;
|
|
const height = meta0.height ?? 0;
|
|
if (width <= 0 || height <= 0) {
|
|
return makePassthrough(input, meta0);
|
|
}
|
|
|
|
const pages = meta0.pages ?? 1;
|
|
if (pages > 1) {
|
|
return makePassthrough(input, meta0);
|
|
}
|
|
|
|
const rawFmt = meta0.format;
|
|
if (rawFmt === 'svg' || rawFmt === 'pdf' || rawFmt === 'heif' || rawFmt === 'jxl' || rawFmt === 'vips') {
|
|
return makePassthrough(input, meta0);
|
|
}
|
|
|
|
const hasAlpha = meta0.hasAlpha === true;
|
|
|
|
try {
|
|
if (hasAlpha) {
|
|
const webpLo = await sharp(input)
|
|
.rotate()
|
|
.ensureAlpha()
|
|
.webp({ lossless: true, effort: WEBP_EFFORT })
|
|
.toBuffer();
|
|
const pngOut = await sharp(input)
|
|
.rotate()
|
|
.ensureAlpha()
|
|
.png({ compressionLevel: 9, adaptiveFiltering: true })
|
|
.toBuffer();
|
|
await sameDimensionsOrThrow(webpLo, width, height);
|
|
await sameDimensionsOrThrow(pngOut, width, height);
|
|
|
|
const pickWebp = webpLo.length <= pngOut.length;
|
|
const outBuf = pickWebp ? webpLo : pngOut;
|
|
if (outBuf.length >= input.length) {
|
|
return makePassthrough(input, meta0);
|
|
}
|
|
return {
|
|
buffer: outBuf,
|
|
mime: pickWebp ? 'image/webp' : 'image/png',
|
|
ext: pickWebp ? 'webp' : 'png',
|
|
width,
|
|
height,
|
|
passthrough: false,
|
|
};
|
|
}
|
|
|
|
const webpColor = await sharp(input)
|
|
.rotate()
|
|
.webp({
|
|
quality: RASTER_QUALITY,
|
|
nearLossless: true,
|
|
effort: WEBP_EFFORT,
|
|
smartSubsample: false,
|
|
})
|
|
.toBuffer();
|
|
const jpegColor = await sharp(input)
|
|
.rotate()
|
|
.jpeg({
|
|
quality: RASTER_QUALITY,
|
|
chromaSubsampling: '4:4:4',
|
|
mozjpeg: true,
|
|
optimizeScans: true,
|
|
})
|
|
.toBuffer();
|
|
await sameDimensionsOrThrow(webpColor, width, height);
|
|
await sameDimensionsOrThrow(jpegColor, width, height);
|
|
|
|
const useWebp = webpColor.length <= jpegColor.length;
|
|
const outBuf = useWebp ? webpColor : jpegColor;
|
|
if (outBuf.length >= input.length) {
|
|
return makePassthrough(input, meta0);
|
|
}
|
|
return {
|
|
buffer: outBuf,
|
|
mime: useWebp ? 'image/webp' : 'image/jpeg',
|
|
ext: useWebp ? 'webp' : 'jpg',
|
|
width,
|
|
height,
|
|
passthrough: false,
|
|
};
|
|
} catch (e) {
|
|
if (e && typeof e === 'object' && /** @type {{ code?: string }} */ (e).code === 'DIM') {
|
|
return makePassthrough(input, meta0);
|
|
}
|
|
return makePassthrough(input, meta0);
|
|
}
|
|
}
|