Electron 打包Squoosh/lib 的图片压缩工具(IM Compressor)
最近用一个礼拜时间上手了一个图片批量压缩软件。起因是找遍了内外网 都找不到一款合适压缩工具。大部分的图片压缩工具的流程无非就是:
1,在线的,你要先来个登录。
2,如果想多压缩几张,不好意思先来个vip充值。
3,离线无法使用。
另外就是第一个安全问题:如果是图片涉及到一些敏感信息,我是不敢上次到服务器上压缩。
作为一个开发人员,于是就开始了图片压缩技术的一些调研。
目前国外一些有名的在线网站:
还有很多很多......
也找了一些开源的库:
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 就这样上线了。
也欢迎大家提出宝贵的意见。最后给一个软件下载入口: