"use strict"; const os = require(`os`); const fs = require("fs"); const path = require("path"); const pretty_ms = require("pretty-ms"); const debug = require("debug")("image"); const imagickal = require("imagickal"); const htmlentities = require("html-entities"); const a = require("./Ansi.js"); function calculate_size(width, height, size_spec) { if(size_spec.indexOf("%") > -1) { // It's a percentage const multiplier = parseInt(size_spec.replace(/%/, ""), 10) / 100; return { width: Math.ceil(width * multiplier), height: Math.ceil(height * multiplier) }; } else { // It's an absolute image width const new_width = parseInt(size_spec, 10); return { width: new_width, height: Math.ceil(new_width/width * height) }; } } // Main task list - we make sure it completes before exiting. var queue = null; var pMemoize = null; async function make_queue() { // 1: Setup task queue const PQueue = (await import("p-queue")).default; let concurrency = os.cpus().length; if(process.env["MAX_CONCURRENT"]) concurrency = parseInt(process.env["MAX_CONCURRENT"], 10); debug(`Image conversion queue concurrency: `, concurrency); queue = new PQueue({ concurrency }); queue.on("idle", () => console.log(`IMAGE ${a.fcyan}all conversions complete${a.reset}`)); process.on("exit", async () => { debug(`Waiting for image conversions to finish...`); await queue.onEmpty(); debug(`All image conversions complete.`); }); } async function srcset(source_image, target_dir, urlpath, format = "__AUTO__", sizes = [ "25%", "50%", "100%" ], quality = 95, strip = true) { if(queue === null) await make_queue(); const source_parsed = path.parse(source_image); // ext contains the dot . already const target_format = format == "__AUTO__" ? source_parsed.ext.replace(/\./g, "") : format; const source_size = await imagickal.dimensions(source_image); debug(`SOURCE_SIZE`, source_size, `TARGET_FORMAT`, target_format); let setitems = await Promise.all(sizes.map(async (size) => { let target_filename = `${source_parsed.name}_${size}.${target_format}` .replace(/%/, "pcent"); let target_current = path.join( target_dir, target_filename ); queue.add(async () => { const start = new Date(); await imagickal.transform(source_image, target_current, { resize: { width: size }, quality, strip }); console.log(`IMAGE\t${a.fcyan}${queue.size}/${queue.pending} tasks${a.reset}\t${a.fyellow}${pretty_ms(new Date() - start)}${a.reset}\t${a.fgreen}${target_current}${a.reset}`); }); // const size_target = await imagickal.dimensions(target_current); const predict = calculate_size(source_size.width, source_size.height, size); // debug(`size spec:`, size, `size predicted: ${predict.width}x${predict.height} actual: ${size_target.width}x${size_target.height}`); return `${path.resolve(urlpath, target_filename)} ${predict.width}w`; })); return setitems.join(", "); } /** * Generates a string of HTML for a element, converting images to the specified formats in the process. * @param {string} source_image The filepath to the source image. * @param {string} alt The alt (alternative) text. Automatically run though htmlentities. * @param {string} target_dir The target directory to save converted images to. * @param {string} urlpath The path to the aforementionoed target directory as a URL. Image paths in the HTML will be prefixed with this value. * @param {string} [formats="__AUTO__"] A list of formats to convert the source image to. Defaults to automatically determining the most optimal formats based on the input format. [must be lowercase] * @param {Array} [sizes=["25%","50%", "100%" ]] The sizes, as imagemagick size specs, to convert the source image to. * @param {Number} [quality=95] The quality value to use when converting images. * @param {Boolean} [strip=true] Whether to strip all metadata from images when converting them [saves some space] * @return {Promise} A Promise that returns a generated string of HTML. */ async function picture(source_image, alt, target_dir, urlpath, formats = "__AUTO__", sizes = [ "25%", "50%", "100%" ], quality = 95, strip = true) { const source_parsed = path.parse(source_image); const source_format = source_parsed.ext.toLowerCase().replace(".", ""); if(formats == "__AUTO__") { switch(source_format) { case "png": case "gif": // * shudder * case "bmp": formats = [ "png" ]; break; default: // jxl = JPEG XL - not currently supported by the old version of imagemagick shipped via apt :-/ // Imagemagick v7+ does support it, but isn't shipped yet :-( formats = [ "jpeg", "webp", "avif", /*"jxl"*/ ]; break; } } const target_original = path.join(target_dir, source_parsed.base); await fs.promises.copyFile(source_image, target_original); const sources = await Promise.all(formats.map(async (format) => { debug(`${format} ${source_image}`); return { mime: `image/${format}`, srcset: await srcset( source_image, target_dir, urlpath, format, sizes, quality, strip ) }; })); let result = `\n\t`; result += sources.map(source => ``).join(`\n\t`); result += `\n\t${htmlentities.encode(alt)}\n`; result += `\n` return result; } var picture_memoize = null; async function setup_memoize() { const pMemoize = (await import("p-memoize")).default; picture_memoize = pMemoize(picture); } module.exports = async function(...args) { if(picture_memoize === null) await setup_memoize(); return await picture_memoize(...args); };