Electron 打包Squoosh/lib 的图片压缩工具(IM Compressor)

Electron 打包Squoosh/lib 的图片压缩工具(IM Compressor)

最近用一个礼拜时间上手了一个图片批量压缩软件。起因是找遍了内外网 都找不到一款合适压缩工具。大部分的图片压缩工具的流程无非就是:

1,在线的,你要先来个登录。

2,如果想多压缩几张,不好意思先来个vip充值。

3,离线无法使用。

另外就是第一个安全问题:如果是图片涉及到一些敏感信息,我是不敢上次到服务器上压缩。

作为一个开发人员,于是就开始了图片压缩技术的一些调研。

目前国外一些有名的在线网站:

https://tinify.cn/

https://compresspng.com/zh/

https://squoosh.app/

还有很多很多......

也找了一些开源的库​:

https://github.com/GoogleChromeLabs/squoosh

https://github.com/Yuriy-Svetlov/compress-images/

........

最终还是选择squoosh 来进行封装。

其实squoosh是有cli库的 squoosh/cli 这个库可以用来批量压缩。但是也有一些缺点。这个库输出的日志较多。于是自己参考squoosh/cli 的命令行封装了自己的“imoo-cli”

nodejs 封装CLI 命令行 可以参考:commander

imoo-cli的代码是参考squoosh/cli 进行了简化的

#!/usr/bin/env node
const program  = require("commander");
//const program = new Command();
const JSON5 = require("json5");
const {ImagePool, encoders, preprocessors} = require("@squoosh/lib");
const  cpus =require("os").cpus;// 'os';
const writeFile = require("fs/promises");
const fromFile = require("file-type").fromFile;
//const fileTypeFromFile = require("file-type");// 'file-type';
const fsp = require("fs/promises");
const path = require("path");

/*根据路径或者目录 获取文件*/
async function getInputFiles(paths) {
    const validFiles = [];

    for (const inputPath of paths) {
        const files = (await fsp.lstat(inputPath)).isDirectory()
            ? (await fsp.readdir(inputPath, { withFileTypes: true }))
                .filter((dirent) => dirent.isFile())
                .map((dirent) => path.join(inputPath, dirent.name))
            : [inputPath];
        for (const file of files) {
            try {
                await fsp.stat(file);
                var ret =await fromFile(file);
                if(ret && ret.mime && ret.mime.split("/")[0] == "image"){
                    validFiles.push(file);
                }else{
                    console.warn(`Warning: Input file does Unsupported file format: ${path.resolve(file)}`);
                }
            } catch (err) {
                if (err.code === 'ENOENT') {
                    console.warn(`Warning: Input file does not exist: ${path.resolve(file)}`);
                    continue;
                } else {
                    throw err;
                }
            }
        }
    }

    return validFiles;
}

//testObj.aa();
async function processFiles(files){
    files = await getInputFiles(files);
    const imagePool = new ImagePool(cpus().length);
    // 创建输出目录
    await fsp.mkdir(program.opts().outputDir, { recursive: true });
    //解码文件
    const results = new Map();
    let decodedFiles = await Promise.all(
        files.map(async (file) => {
            const buffer = await fsp.readFile(file);
            const image = imagePool.ingestImage(buffer);
            await image.decoded;
            results.set(image, {
                file,
                size: (await image.decoded).size,
                outputs: [],
            });
            return image;
        }),
    );


    /*todo:添加预处理参数*/
    const preprocessOptions = {};
    //预处理参数解析
    for (const preprocessorName of Object.keys(preprocessors)) {
        if (!program.opts()[preprocessorName]) {
            continue;
        }
        preprocessOptions[preprocessorName] = JSON5.parse(
            program.opts()[preprocessorName],
        );
    }
    for (const image of decodedFiles) {
        image.preprocess(preprocessOptions);
    }
    /*todo:等待所有的图片对象解码完成*/
    await Promise.all(decodedFiles.map((image) => image.decoded));

    /*todo:执行压缩*/
    const jobs = [];
    let jobsStarted = 0;
    let jobsFinished = 0;
    for (const image of decodedFiles) {
        const originalFile = results.get(image).file;
        const encodeOptions = {
            optimizerButteraugliTarget: Number(program.opts().optimizerButteraugliTarget),
            maxOptimizerRounds: Number(program.opts().maxOptimizerRounds),
        };
        for (const encName of Object.keys(encoders)) {
            if (!program.opts()[encName]) {
                continue;
            }
            const encParam = program.opts()[encName];
            const encConfig =encParam.toLowerCase() === 'auto' ? 'auto' : JSON5.parse(encParam);
            encodeOptions[encName] = encConfig;
        }
        jobsStarted++;
        const job = image.encode(encodeOptions).then(async () => {
            jobsFinished++;
            const outputPath = path.join(
                program.opts().outputDir,
                path.basename(originalFile, path.extname(originalFile)) + program.opts().suffix,
            );
            for (const output of Object.values(image.encodedWith)) {
                const outputObj = await output;
                const outputFile = `${outputPath}.${outputObj.extension}`;
                await fsp.writeFile(outputFile, outputObj.binary);
                results.get(image).outputs.push(Object.assign(outputObj, { outputFile }));
                const resJSON = {
                    status:0,
                    totalNum:jobsStarted,
                    finishedNul:jobsFinished,
                    originalFile:results.get(image).file,
                    outputPath:outputFile,
                    outputSize:outputObj.size,
                    outputExtension:outputObj.extension,
                };
                //输出到命令行
                console.log(JSON5.stringify(resJSON));
            }
           // progress.setProgress(jobsFinished, jobsStarted);
        });
        jobs.push(job);
    }
    // 等待任务完成
    await Promise.all(jobs);
    await imagePool.close();
}
program.version('0.0.1');
program
    .name('imoo-cli')
    .arguments('<files...>')
    .option('-d, --output-dir <dir>', 'Output directory', '.')
    .option('-s, --suffix <suffix>', 'Append suffix to output files', '')
    .option(
        '--max-optimizer-rounds <rounds>',
        'Maximum number of compressions to use for auto optimizations',
        '6',
    )
    .option(
        '--optimizer-butteraugli-target <butteraugli distance>',
        'Target Butteraugli distance for auto optimizer',
        '1.4',
    )
    .action(processFiles);
// Create a CLI option for each supported preprocessor
for (const [key, value] of Object.entries(preprocessors)) {
    program.option(`--${key} [config]`, value.description);
}
// Create a CLI option for each supported encoder
for (const [key, value] of Object.entries(encoders)) {
    program.option(
        `--${key} [config]`,
        `Use ${value.name} to generate a .${value.extension} file with the given configuration`,
    );
}

program.parse(process.argv);

要注意的是:CLI 是依赖node js运行的,而且必须是指定的nodejs版本 我这里用的是nodejs V16 的版本。在其他版本上会有各种各样的问题。

另外一个就是关于打包到Electron的时候 由于程序中是用require('child_process').spawn; 子进程的方式调用cli库的。而子进程指定了在shell中执行。打包软件安装后 shell 会去找系统中的 node环境。 由于用户电脑上不一定会安装nodejs。导致spawn 调用cli的命令失败。找遍了内外网,甚至ChatGPT也没给出好的解决方案。(如果这个过程有熟悉的朋友可以告知下.....)

后来没办法 只能使用pkg 将imoo-cli 打包成exe可执行文件。这个打包会把对应的node环境和命令行程序一起打包成可执行程序:

"pkg": {
    "targets": [
      "node16-win-x64",
      "node16-mac-x64"
    ]
  }

这样就把单独的imoo-cli 独立出来了。在不同的平台上打包成不同的可执行文件,解决了Electron 打包文件安装后,不要求客户机器安装node环境的问题了。因为可执行文件中用pkg打包了对应的环境。

接下来就是Electron的UI制作了。这里我参考了https://tinify.cn/ 的一些UI,

用Vite + VUE 做UI 实在是非常的爽,桌面软件么不用考虑SEO的问题。所以就放开了弄。

下面的软件的一些截图:

还有很多没有细化的地方:

比如说

1,添加图片后本来是想吧缩略图给显示的,结果如果是添加1000张,Electron有些抗不住,结果只能用默认的图做代替。(本来是想用canvas 做小图的,由于时间问题还是作罢了)

2,压缩前后的对比,如果是压缩完成后能直接预览前后的区别,会更人性化。这里还没有去弄。

3,多进程执行压缩(暂时还没考虑.......),其实是有必要的可以加快整体的压缩时间。

4,安装包过大的问题。

这样一款:不用登录注册,没有数量限制,没有广告弹窗,下载安装即可使用的批量图片压缩软件 IM Compressor 就这样上线了。

也欢迎大家提出宝贵的意见。最后给一个软件下载入口:

https://img.imoolee.com/