gulp-libsquoosh
Version:
Minify images with libSquoosh, the Squoosh API for Node.
289 lines (239 loc) • 7.36 kB
JavaScript
;
const os = require('os');
const through = require('through2');
const PluginError = require('plugin-error');
const libSquoosh = require('@squoosh/lib');
const debounce = require('lodash.debounce');
const PLUGIN_NAME = 'gulp-libsquoosh';
// How many parallel operations allowed while processing ImagePool.
const NUM_PARALLEL = os.cpus().length > 1 ? 2 : 1;
// Reuse ImagePool every 5 images.
const REUSE_IMAGEPOOL = 5;
/** @type {libSquoosh.ImagePool} */
let imagePool;
const queue = [];
let running = 0;
let processed = 0;
/**
* By default, encode to same image type.
* @typedef {[extension:string]: Object}
*/
const DefaultEncodeOptions = Object.fromEntries(
Object.entries(libSquoosh.encoders).map(([key, encoder]) => {
const extension = `.${encoder.extension}`;
return [extension, Object.fromEntries([[key, {}]])];
})
);
/**
* @typedef { import('vinyl') } File
*/
/**
* @typedef {Object} BoxSize
* @property {number} width
* @property {number} height
*/
/**
* @typedef {Object} SquooshOptions
* @property {EncodeOptions} encodeOptions
* @property {PreprocessOptions} preprocessOptions
*/
/**
* @callback SquooshCallback
* @param {ImageSize} imageSize
* @returns {BoxSize}
*/
/* The following two options are as of libSquoosh's commit #955b2ac. */
/**
* @typedef {Object} EncodeOptions
* @property {Object} [mozjpeg]
* @property {Object} [webp]
* @property {Object} [avif]
* @property {Object} [jxl]
* @property {Object} [wp2]
* @property {Object} [oxipng]
*/
/**
* @typedef {Object} PreprocessOptions
* @property {Object} [resize]
* @property {Object} [quant]
* @property {Object} [rotate]
*/
/**
* Close ImagePool instance when idle.
* If you don't close imagePool when idle, gulp should hang.
*/
const closeImagePoolWhenIdle = debounce(() => {
(async () => {
if (imagePool) {
await imagePool.close();
imagePool = null;
}
})();
}, 500);
/**
* Minify images with libSquoosh.
* @param {(EncodeOptions|SquooshOptions|SquooshCallback)} [encodeOptions] - An object with encoders to use, and their settings.
* @param {Object} [PreprocessOptions] - An object with preprocessors to use, and their settings.
* @returns {NodeJS.ReadWriteStream}
*/
function squoosh(encodeOptions, preprocessOptions) {
if (typeof encodeOptions === 'object' && typeof preprocessOptions === 'undefined') {
if (typeof encodeOptions.preprocessOptions !== 'undefined') {
preprocessOptions = encodeOptions.preprocessOptions;
delete encodeOptions.preprocessOptions;
}
if (typeof encodeOptions.encodeOptions !== 'undefined') {
encodeOptions = encodeOptions.encodeOptions;
}
}
/**
* @param {File} file
* @returns {File[]}
*/
const encode = async function (file, encodeOptions, preprocessOptions) {
closeImagePoolWhenIdle.cancel(); // Stop debounce timer
if (!imagePool) {
imagePool = new libSquoosh.ImagePool(NUM_PARALLEL);
}
let currentEncodeOptions = encodeOptions;
let currentPreprocessOptions = preprocessOptions;
const image = imagePool.ingestImage(file.contents);
const decoded = await image.decoded;
if (typeof encodeOptions === 'function') {
/** @type {SquooshCallback} */
const callback = encodeOptions;
const result = callback(new ImageSize(decoded, file.path));
currentEncodeOptions = result.encodeOptions || null;
currentPreprocessOptions = result.preprocessOptions || null;
}
currentEncodeOptions = (currentEncodeOptions && Object.keys(currentEncodeOptions).length > 0) ? currentEncodeOptions : DefaultEncodeOptions[file.extname];
if (currentPreprocessOptions) {
await image.preprocess(currentPreprocessOptions);
}
await image.encode(currentEncodeOptions);
const encodedFiles = [];
const tasks = Object.values(image.encodedWith).map(async encoder => {
const encodedImage = await encoder;
const newfile = file.clone({contents: false});
newfile.contents = Buffer.from(encodedImage.binary);
newfile.extname = `.${encodedImage.extension}`;
encodedFiles.push(newfile);
});
await Promise.all(tasks);
processed++;
if (processed >= REUSE_IMAGEPOOL) {
await imagePool.close();
imagePool = null;
processed = 0;
}
closeImagePoolWhenIdle();
return encodedFiles;
};
/**
* @type { import('through2').TransformFunction }
* @param {File} file
*/
const transform = async function (file, enc, cb) {
if (file.isNull()) {
cb(null, file);
return;
}
if (file.isStream()) {
cb(new PluginError(PLUGIN_NAME, 'Streaming not supported'));
return;
}
// Is file supported by libsquoosh?
if (!Object.keys(DefaultEncodeOptions).includes(file.extname)) {
cb(null, file);
return;
}
queue.push([this, file, encodeOptions, preprocessOptions, cb]);
if (running < 1) {
running++;
for (let args; (args = queue.shift());) {
const [self, file, encodeOptions, preprocessOptions, cb] = args;
try {
const encoded = await encode(file, encodeOptions, preprocessOptions); // eslint-disable-line no-await-in-loop
for (const f of encoded) {
self.push(f);
}
cb();
} catch (error) {
cb(new PluginError(PLUGIN_NAME, error, {filename: file.path}));
}
}
running--;
}
};
return through.obj(transform);
}
/**
* @class
* @param {Object} bitmap
* @param {string} path - The full path to the file.
*/
function ImageSize({bitmap}, path) {
/** @type {number} */
this.width = bitmap.width;
/** @type {number} */
this.height = bitmap.height;
this.path = path;
}
/**
* Scale to keep its aspect ratio while fitting within the specified bounding box.
* @param {number} targetWidth
* @param {number} [targetHeight]
* @returns {BoxSize}
*/
ImageSize.prototype.contain = function (targetWidth, targetHeight) {
if (typeof targetHeight === 'undefined') {
targetHeight = targetWidth;
}
const {width, height} = this;
const scaleW = targetWidth / width;
const scaleH = targetHeight / height;
const scale = (scaleW > scaleH) ? scaleH : scaleW;
return {
width: Math.round(width * scale),
height: Math.round(height * scale)
};
};
/**
* Acts like contain() but don't zoom if image is smaller than the specified bounding box.
* @param {number} targetWidth
* @param {number} [targetHeight]
* @returns {BoxSize}
*/
ImageSize.prototype.scaleDown = function (targetWidth, targetHeight) {
if (typeof targetHeight === 'undefined') {
targetHeight = targetWidth;
}
const {width, height} = this;
if (targetWidth > width && targetHeight > height) {
return {width, height};
}
return this.contain(targetWidth, targetHeight);
};
/**
* Scale to keep its aspect ratio while filling the specified bounding box.
* This method is not usable because libSquoosh doesn't provide crop functionality.
* @param {number} targetWidth
* @param {number} [targetHeight]
* @returns {BoxSize}
*/
ImageSize.prototype.cover = function (targetWidth, targetHeight) {
if (typeof targetHeight === 'undefined') {
targetHeight = targetWidth;
}
const {width, height} = this;
const scaleW = targetWidth / width;
const scaleH = targetHeight / height;
const scale = (scaleW > scaleH) ? scaleW : scaleH;
return {
width: Math.round(width * scale),
height: Math.round(height * scale)
};
};
squoosh.DefaultEncodeOptions = DefaultEncodeOptions;
squoosh.ImageSize = ImageSize;
module.exports = squoosh;