minify-pic-cli 1.1.0 → 1.2.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/index.js +210 -12
- package/lib/progress-display.js +182 -0
- package/lib/worker-pool.js +336 -0
- package/lib/worker.js +116 -0
- package/package.json +8 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Minify Pic CLI - Batch Image Compression Tool
|
|
2
2
|
|
|
3
|
-
English | [简体中文](
|
|
3
|
+
English | [简体中文](https://github.com/Kevin031/minify-pic-cli/blob/main/README.zh-CN.md)
|
|
4
4
|
|
|
5
5
|
A simple and easy-to-use command-line tool for batch image compression, supporting PNG, JPEG, and GIF formats. Perfect for frontend developers and designers to quickly optimize image file sizes.
|
|
6
6
|
|
package/index.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
1
|
+
#!/usr/bin/env node
|
|
2
2
|
const fs = require("fs");
|
|
3
3
|
const path = require("path");
|
|
4
4
|
const sharp = require("sharp");
|
|
5
5
|
const readline = require("readline");
|
|
6
6
|
const { Command } = require("commander");
|
|
7
|
+
const os = require("os");
|
|
8
|
+
const WorkerPool = require("./lib/worker-pool");
|
|
9
|
+
const ProgressDisplay = require("./lib/progress-display");
|
|
7
10
|
|
|
8
11
|
// 默认配置参数
|
|
9
12
|
const DEFAULT_CONFIG = {
|
|
@@ -117,7 +120,40 @@ async function compressImage(filePath, config, baseDir = config.targetDir) {
|
|
|
117
120
|
}
|
|
118
121
|
}
|
|
119
122
|
|
|
120
|
-
//
|
|
123
|
+
// 递归扫描目录,收集所有图片文件
|
|
124
|
+
function collectImageFiles(dir, config, baseDir = config.targetDir) {
|
|
125
|
+
const files = fs.readdirSync(dir);
|
|
126
|
+
const imageFiles = [];
|
|
127
|
+
|
|
128
|
+
for (const file of files) {
|
|
129
|
+
const filePath = path.join(dir, file);
|
|
130
|
+
const stat = fs.statSync(filePath);
|
|
131
|
+
|
|
132
|
+
// 跳过 output 目录(非原地替换模式)
|
|
133
|
+
if (!config.replace && filePath === config.outputDir) {
|
|
134
|
+
console.log(`跳过的目录: ${filePath}(为输出目录)`);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (stat.isDirectory()) {
|
|
139
|
+
if (!config.blackDirs.includes(file)) {
|
|
140
|
+
const subFiles = collectImageFiles(filePath, config, baseDir);
|
|
141
|
+
imageFiles.push(...subFiles);
|
|
142
|
+
} else {
|
|
143
|
+
console.log(`跳过的目录: ${filePath}`);
|
|
144
|
+
}
|
|
145
|
+
} else if (/\.(png|jpe?g|gif)$/i.test(filePath)) {
|
|
146
|
+
imageFiles.push({
|
|
147
|
+
filePath,
|
|
148
|
+
size: stat.size
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return imageFiles;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 递归压缩目录下所有图片(单进程模式)
|
|
121
157
|
async function compressFiles(dir, config, baseDir = config.targetDir) {
|
|
122
158
|
const files = fs.readdirSync(dir);
|
|
123
159
|
|
|
@@ -143,6 +179,115 @@ async function compressFiles(dir, config, baseDir = config.targetDir) {
|
|
|
143
179
|
}
|
|
144
180
|
}
|
|
145
181
|
|
|
182
|
+
// 多进程压缩
|
|
183
|
+
async function compressFilesParallel(imageFiles, config, baseDir, setGlobalPool) {
|
|
184
|
+
const workerCount = WorkerPool.calculateWorkerCount(
|
|
185
|
+
imageFiles.length,
|
|
186
|
+
config.workers
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
// 如果计算结果是0或用户指定不使用并行,使用单进程模式
|
|
190
|
+
if (workerCount === 0 || config.noParallel) {
|
|
191
|
+
console.log(`使用单进程模式压缩 ${imageFiles.length} 张图片`);
|
|
192
|
+
const progress = new ProgressDisplay(imageFiles.length);
|
|
193
|
+
progress.setMultiProcess(false);
|
|
194
|
+
|
|
195
|
+
const startTime = Date.now();
|
|
196
|
+
let completed = 0;
|
|
197
|
+
let failed = 0;
|
|
198
|
+
let totalBeforeSize = 0;
|
|
199
|
+
let totalAfterSize = 0;
|
|
200
|
+
|
|
201
|
+
for (const fileInfo of imageFiles) {
|
|
202
|
+
try {
|
|
203
|
+
const beforeSize = fileInfo.size;
|
|
204
|
+
await compressImage(fileInfo.filePath, config, baseDir);
|
|
205
|
+
const afterSize = fs.statSync(
|
|
206
|
+
config.replace ? fileInfo.filePath :
|
|
207
|
+
path.join(config.outputDir, path.relative(baseDir, fileInfo.filePath))
|
|
208
|
+
).size;
|
|
209
|
+
|
|
210
|
+
completed++;
|
|
211
|
+
totalBeforeSize += beforeSize;
|
|
212
|
+
totalAfterSize += afterSize;
|
|
213
|
+
} catch (error) {
|
|
214
|
+
failed++;
|
|
215
|
+
console.error(`压缩出错: ${fileInfo.filePath}`, error.message);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const duration = (Date.now() - startTime) / 1000;
|
|
220
|
+
const speed = completed / duration;
|
|
221
|
+
const savedSize = totalBeforeSize - totalAfterSize;
|
|
222
|
+
const compressionRate = totalBeforeSize > 0
|
|
223
|
+
? ((savedSize / totalBeforeSize) * 100).toFixed(1)
|
|
224
|
+
: 0;
|
|
225
|
+
|
|
226
|
+
progress.showReport({
|
|
227
|
+
total: imageFiles.length,
|
|
228
|
+
completed,
|
|
229
|
+
failed,
|
|
230
|
+
duration,
|
|
231
|
+
speed: speed.toFixed(1),
|
|
232
|
+
totalBeforeSize,
|
|
233
|
+
totalAfterSize,
|
|
234
|
+
savedSize,
|
|
235
|
+
compressionRate,
|
|
236
|
+
workerCount: 0,
|
|
237
|
+
failedTasks: []
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// 多进程模式
|
|
244
|
+
console.log(`检测到 ${imageFiles.length} 张图片需要压缩`);
|
|
245
|
+
console.log(`将使用 ${workerCount} 个进程并行处理以加快速度`);
|
|
246
|
+
|
|
247
|
+
const progress = new ProgressDisplay(imageFiles.length);
|
|
248
|
+
progress.setMultiProcess(true);
|
|
249
|
+
|
|
250
|
+
const pool = new WorkerPool({
|
|
251
|
+
quality: config.quality,
|
|
252
|
+
gifColours: config.gifColours,
|
|
253
|
+
outputDir: config.outputDir,
|
|
254
|
+
replace: config.replace,
|
|
255
|
+
baseDir: baseDir,
|
|
256
|
+
onProgress: (data) => {
|
|
257
|
+
progress.update(data);
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// 设置全局引用,用于优雅退出
|
|
262
|
+
if (setGlobalPool) {
|
|
263
|
+
setGlobalPool(pool);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
try {
|
|
267
|
+
await pool.initialize(workerCount);
|
|
268
|
+
pool.addTasks(imageFiles);
|
|
269
|
+
pool.start();
|
|
270
|
+
await pool.waitForCompletion();
|
|
271
|
+
|
|
272
|
+
// 获取最终统计数据
|
|
273
|
+
const report = pool.getReport();
|
|
274
|
+
|
|
275
|
+
// 更新并显示最终进度
|
|
276
|
+
progress.completed = report.completed;
|
|
277
|
+
progress.failed = report.failed;
|
|
278
|
+
progress.totalBeforeSize = report.totalBeforeSize;
|
|
279
|
+
progress.totalAfterSize = report.totalAfterSize;
|
|
280
|
+
progress.complete();
|
|
281
|
+
|
|
282
|
+
progress.showReport(report);
|
|
283
|
+
} catch (error) {
|
|
284
|
+
console.error("多进程压缩失败:", error);
|
|
285
|
+
throw error;
|
|
286
|
+
} finally {
|
|
287
|
+
await pool.shutdown();
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
146
291
|
// commander命令行包装
|
|
147
292
|
const program = new Command();
|
|
148
293
|
|
|
@@ -156,8 +301,34 @@ program
|
|
|
156
301
|
.option("-b, --black-dirs <dirs>", "排除的子文件夹名称(逗号分隔)", val => val.split(","), DEFAULT_CONFIG.blackDirs)
|
|
157
302
|
.option("-y, --yes", "跳过确认,直接开始压缩")
|
|
158
303
|
.option("-r, --replace", "原地压缩替换原文件,不输出到新目录")
|
|
304
|
+
.option("-w, --workers <num>", "指定并发工作进程数")
|
|
305
|
+
.option("--no-parallel", "禁用多进程,使用单进程模式")
|
|
159
306
|
.version(require('./package.json').version, '-v, --version', '显示版本号')
|
|
160
307
|
.action(async (options) => {
|
|
308
|
+
// 全局工作进程池引用,用于优雅退出
|
|
309
|
+
let globalPool = null;
|
|
310
|
+
|
|
311
|
+
// 优雅退出处理
|
|
312
|
+
const gracefulShutdown = async (signal) => {
|
|
313
|
+
console.log(`\n\n接收到 ${signal} 信号,正在优雅退出...`);
|
|
314
|
+
|
|
315
|
+
if (globalPool) {
|
|
316
|
+
console.log("正在等待当前任务完成...");
|
|
317
|
+
try {
|
|
318
|
+
await globalPool.shutdown();
|
|
319
|
+
console.log("已安全关闭所有工作进程");
|
|
320
|
+
} catch (error) {
|
|
321
|
+
console.error("关闭工作进程时出错:", error);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
console.log("程序已退出");
|
|
326
|
+
process.exit(0);
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
// 注册信号处理
|
|
330
|
+
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
|
331
|
+
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
|
161
332
|
// 合并配置
|
|
162
333
|
const config = {
|
|
163
334
|
targetDir: path.resolve(process.cwd(), options.dir),
|
|
@@ -166,6 +337,8 @@ program
|
|
|
166
337
|
gifColours: parseInt(options.gifColours, 10),
|
|
167
338
|
blackDirs: Array.isArray(options.blackDirs) ? options.blackDirs : [options.blackDirs],
|
|
168
339
|
replace: options.replace || false,
|
|
340
|
+
workers: options.workers,
|
|
341
|
+
noParallel: options.noParallel || false,
|
|
169
342
|
};
|
|
170
343
|
|
|
171
344
|
let shouldContinue = options.yes;
|
|
@@ -178,17 +351,42 @@ program
|
|
|
178
351
|
}
|
|
179
352
|
|
|
180
353
|
const inputDir = config.targetDir;
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
354
|
+
|
|
355
|
+
// 收集所有图片文件
|
|
356
|
+
console.log("正在扫描图片文件...");
|
|
357
|
+
const imageFiles = collectImageFiles(inputDir, config, inputDir);
|
|
358
|
+
|
|
359
|
+
if (imageFiles.length === 0) {
|
|
360
|
+
console.log("未找到需要压缩的图片文件");
|
|
361
|
+
process.exit(0);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
try {
|
|
365
|
+
await compressFilesParallel(imageFiles, config, inputDir, (pool) => {
|
|
366
|
+
globalPool = pool;
|
|
191
367
|
});
|
|
368
|
+
|
|
369
|
+
// 解除全局引用
|
|
370
|
+
globalPool = null;
|
|
371
|
+
|
|
372
|
+
if (config.replace) {
|
|
373
|
+
console.log("\n压缩任务全部完成,已原地替换所有图片");
|
|
374
|
+
} else {
|
|
375
|
+
console.log("\n输出目录:", config.outputDir);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// 正常退出
|
|
379
|
+
process.exit(0);
|
|
380
|
+
} catch (err) {
|
|
381
|
+
console.error("压缩文件时出错:", err);
|
|
382
|
+
|
|
383
|
+
// 确保清理工作进程
|
|
384
|
+
if (globalPool) {
|
|
385
|
+
await globalPool.shutdown();
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
process.exit(1);
|
|
389
|
+
}
|
|
192
390
|
});
|
|
193
391
|
|
|
194
392
|
program.parse(process.argv);
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
const readline = require("readline");
|
|
2
|
+
|
|
3
|
+
class ProgressDisplay {
|
|
4
|
+
constructor(total) {
|
|
5
|
+
this.total = total;
|
|
6
|
+
this.completed = 0;
|
|
7
|
+
this.failed = 0;
|
|
8
|
+
this.startTime = Date.now();
|
|
9
|
+
this.totalBeforeSize = 0;
|
|
10
|
+
this.totalAfterSize = 0;
|
|
11
|
+
this.lastUpdateTime = Date.now();
|
|
12
|
+
this.isMultiProcess = false;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// 设置是否多进程模式
|
|
16
|
+
setMultiProcess(isMultiProcess) {
|
|
17
|
+
this.isMultiProcess = isMultiProcess;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// 格式化文件大小
|
|
21
|
+
formatSize(bytes) {
|
|
22
|
+
if (bytes === 0) return "0B";
|
|
23
|
+
const mb = bytes / 1024 / 1024;
|
|
24
|
+
if (mb >= 1) {
|
|
25
|
+
return mb.toFixed(2) + "MB";
|
|
26
|
+
}
|
|
27
|
+
const kb = bytes / 1024;
|
|
28
|
+
if (kb < 0.01 && bytes > 0) {
|
|
29
|
+
return "<0.01KB";
|
|
30
|
+
}
|
|
31
|
+
return kb.toFixed(2) + "KB";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 格式化时间
|
|
35
|
+
formatTime(seconds) {
|
|
36
|
+
if (seconds < 60) {
|
|
37
|
+
return Math.round(seconds) + " 秒";
|
|
38
|
+
}
|
|
39
|
+
const minutes = Math.floor(seconds / 60);
|
|
40
|
+
const remainSeconds = Math.round(seconds % 60);
|
|
41
|
+
return `${minutes} 分 ${remainSeconds} 秒`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 生成进度条
|
|
45
|
+
generateProgressBar(percent, length = 30) {
|
|
46
|
+
const filled = Math.round((percent / 100) * length);
|
|
47
|
+
const empty = length - filled;
|
|
48
|
+
return "█".repeat(filled) + "░".repeat(empty);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 更新进度(多进程模式)
|
|
52
|
+
updateMultiProcess(data) {
|
|
53
|
+
if (data.completed !== undefined) {
|
|
54
|
+
this.completed = data.completed;
|
|
55
|
+
}
|
|
56
|
+
if (data.failed !== undefined) {
|
|
57
|
+
this.failed = data.failed;
|
|
58
|
+
}
|
|
59
|
+
if (data.beforeSize) {
|
|
60
|
+
this.totalBeforeSize += data.beforeSize;
|
|
61
|
+
}
|
|
62
|
+
if (data.afterSize) {
|
|
63
|
+
this.totalAfterSize += data.afterSize;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 限制更新频率(每200ms更新一次)
|
|
67
|
+
const now = Date.now();
|
|
68
|
+
if (now - this.lastUpdateTime < 200) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
this.lastUpdateTime = now;
|
|
72
|
+
|
|
73
|
+
const percent = ((this.completed + this.failed) / this.total * 100).toFixed(1);
|
|
74
|
+
const elapsed = (now - this.startTime) / 1000;
|
|
75
|
+
const speed = this.completed / elapsed;
|
|
76
|
+
const remaining = this.total - this.completed - this.failed;
|
|
77
|
+
const eta = remaining > 0 ? remaining / speed : 0;
|
|
78
|
+
|
|
79
|
+
const progressBar = this.generateProgressBar(parseFloat(percent));
|
|
80
|
+
const savedSize = this.formatSize(this.totalBeforeSize - this.totalAfterSize);
|
|
81
|
+
|
|
82
|
+
// 使用 \r 实现同行刷新
|
|
83
|
+
process.stdout.write(
|
|
84
|
+
`\r[${progressBar}] ${this.completed + this.failed}/${this.total} (${percent}%) | ` +
|
|
85
|
+
`速度: ${speed.toFixed(1)} 张/秒 | ` +
|
|
86
|
+
`剩余: ${this.formatTime(eta)} | ` +
|
|
87
|
+
`已节省: ${savedSize}`
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 更新进度(单进程模式)
|
|
92
|
+
updateSingleProcess(data) {
|
|
93
|
+
this.completed++;
|
|
94
|
+
if (data.beforeSize) {
|
|
95
|
+
this.totalBeforeSize += data.beforeSize;
|
|
96
|
+
}
|
|
97
|
+
if (data.afterSize) {
|
|
98
|
+
this.totalAfterSize += data.afterSize;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const beforeSize = this.formatSize(data.beforeSize);
|
|
102
|
+
const afterSize = this.formatSize(data.afterSize);
|
|
103
|
+
|
|
104
|
+
console.log(
|
|
105
|
+
`压缩完成 [大小变化: ${beforeSize} ---->>>> ${afterSize}] ${data.outputPath}`
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 更新进度
|
|
110
|
+
update(data) {
|
|
111
|
+
if (this.isMultiProcess) {
|
|
112
|
+
this.updateMultiProcess(data);
|
|
113
|
+
} else {
|
|
114
|
+
this.updateSingleProcess(data);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 完成进度显示
|
|
119
|
+
complete() {
|
|
120
|
+
if (this.isMultiProcess) {
|
|
121
|
+
// 强制显示最终进度(100%)
|
|
122
|
+
const percent = ((this.completed + this.failed) / this.total * 100).toFixed(1);
|
|
123
|
+
const elapsed = (Date.now() - this.startTime) / 1000;
|
|
124
|
+
const speed = this.completed / elapsed;
|
|
125
|
+
const progressBar = this.generateProgressBar(parseFloat(percent));
|
|
126
|
+
const savedSize = this.formatSize(this.totalBeforeSize - this.totalAfterSize);
|
|
127
|
+
|
|
128
|
+
// 最终进度输出
|
|
129
|
+
process.stdout.write(
|
|
130
|
+
`\r[${progressBar}] ${this.completed + this.failed}/${this.total} (${percent}%) | ` +
|
|
131
|
+
`速度: ${speed.toFixed(1)} 张/秒 | ` +
|
|
132
|
+
`完成 | ` +
|
|
133
|
+
`已节省: ${savedSize}`
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
// 多进程模式:换行,准备输出报告
|
|
137
|
+
console.log("\n");
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// 显示错误
|
|
142
|
+
showError(filePath, error) {
|
|
143
|
+
if (!this.isMultiProcess) {
|
|
144
|
+
console.error(`压缩出错: ${filePath}`, error);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// 显示最终报告
|
|
149
|
+
showReport(report) {
|
|
150
|
+
console.log("========================================");
|
|
151
|
+
console.log("压缩任务完成报告");
|
|
152
|
+
console.log("========================================");
|
|
153
|
+
console.log(`总处理图片: ${report.total} 张`);
|
|
154
|
+
console.log(`成功: ${report.completed} 张`);
|
|
155
|
+
console.log(`失败: ${report.failed} 张`);
|
|
156
|
+
console.log(`总耗时: ${this.formatTime(report.duration)}`);
|
|
157
|
+
console.log(`平均速度: ${report.speed} 张/秒`);
|
|
158
|
+
|
|
159
|
+
const beforeSize = this.formatSize(report.totalBeforeSize);
|
|
160
|
+
const afterSize = this.formatSize(report.totalAfterSize);
|
|
161
|
+
const savedSize = this.formatSize(report.savedSize);
|
|
162
|
+
|
|
163
|
+
console.log(`总节省空间: ${savedSize} (原始 ${beforeSize} → 压缩后 ${afterSize})`);
|
|
164
|
+
console.log(`压缩率: ${report.compressionRate}%`);
|
|
165
|
+
|
|
166
|
+
if (report.workerCount > 0) {
|
|
167
|
+
console.log(`使用进程数: ${report.workerCount}`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
console.log("========================================");
|
|
171
|
+
|
|
172
|
+
if (report.failedTasks && report.failedTasks.length > 0) {
|
|
173
|
+
console.log("失败文件列表:");
|
|
174
|
+
for (const task of report.failedTasks) {
|
|
175
|
+
console.log(` - ${task.filePath} (${task.error})`);
|
|
176
|
+
}
|
|
177
|
+
console.log("========================================");
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
module.exports = ProgressDisplay;
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
const { fork } = require("child_process");
|
|
2
|
+
const os = require("os");
|
|
3
|
+
const path = require("path");
|
|
4
|
+
|
|
5
|
+
class WorkerPool {
|
|
6
|
+
constructor(config) {
|
|
7
|
+
this.config = config;
|
|
8
|
+
this.workers = [];
|
|
9
|
+
this.taskQueue = [];
|
|
10
|
+
this.activeTasks = new Map(); // taskId -> task info
|
|
11
|
+
this.completedTasks = [];
|
|
12
|
+
this.failedTasks = [];
|
|
13
|
+
this.nextTaskId = 0;
|
|
14
|
+
this.startTime = null;
|
|
15
|
+
this.isShuttingDown = false;
|
|
16
|
+
|
|
17
|
+
// 统计信息
|
|
18
|
+
this.stats = {
|
|
19
|
+
total: 0,
|
|
20
|
+
completed: 0,
|
|
21
|
+
failed: 0,
|
|
22
|
+
totalBeforeSize: 0,
|
|
23
|
+
totalAfterSize: 0,
|
|
24
|
+
workerCount: 0
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// 计算最优进程数
|
|
29
|
+
static calculateWorkerCount(imageCount, userSpecified) {
|
|
30
|
+
const cpuCount = os.cpus().length;
|
|
31
|
+
|
|
32
|
+
// 用户指定进程数
|
|
33
|
+
if (userSpecified) {
|
|
34
|
+
const count = parseInt(userSpecified, 10);
|
|
35
|
+
if (count >= 1 && count <= cpuCount) {
|
|
36
|
+
return count;
|
|
37
|
+
}
|
|
38
|
+
console.log(`警告: 指定的进程数 ${userSpecified} 不合理,将使用自动计算值`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 自动计算
|
|
42
|
+
if (imageCount <= 20) {
|
|
43
|
+
return 0; // 使用单进程模式
|
|
44
|
+
} else if (imageCount <= cpuCount) {
|
|
45
|
+
return imageCount;
|
|
46
|
+
} else {
|
|
47
|
+
return Math.max(1, cpuCount - 1);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 初始化工作进程池
|
|
52
|
+
async initialize(workerCount) {
|
|
53
|
+
this.stats.workerCount = workerCount;
|
|
54
|
+
this.startTime = Date.now();
|
|
55
|
+
|
|
56
|
+
const workerPath = path.join(__dirname, "worker.js");
|
|
57
|
+
const promises = [];
|
|
58
|
+
|
|
59
|
+
for (let i = 0; i < workerCount; i++) {
|
|
60
|
+
const promise = new Promise((resolve, reject) => {
|
|
61
|
+
const worker = fork(workerPath);
|
|
62
|
+
|
|
63
|
+
worker.on("message", (message) => {
|
|
64
|
+
if (message.type === "ready") {
|
|
65
|
+
this.workers.push(worker);
|
|
66
|
+
resolve();
|
|
67
|
+
} else {
|
|
68
|
+
this.handleWorkerMessage(worker, message);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
worker.on("error", (error) => {
|
|
73
|
+
console.error(`工作进程错误:`, error);
|
|
74
|
+
reject(error);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
worker.on("exit", (code) => {
|
|
78
|
+
if (!this.isShuttingDown && code !== 0) {
|
|
79
|
+
console.error(`工作进程异常退出,退出码: ${code}`);
|
|
80
|
+
this.handleWorkerExit(worker);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// 超时处理
|
|
85
|
+
setTimeout(() => reject(new Error("工作进程启动超时")), 10000);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
promises.push(promise);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
await Promise.all(promises);
|
|
92
|
+
console.log(`已启动 ${workerCount} 个工作进程`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 处理工作进程消息
|
|
96
|
+
handleWorkerMessage(worker, message) {
|
|
97
|
+
if (message.type === "success") {
|
|
98
|
+
const task = this.activeTasks.get(message.taskId);
|
|
99
|
+
if (task) {
|
|
100
|
+
this.activeTasks.delete(message.taskId);
|
|
101
|
+
this.completedTasks.push({
|
|
102
|
+
...message,
|
|
103
|
+
task
|
|
104
|
+
});
|
|
105
|
+
this.stats.completed++;
|
|
106
|
+
this.stats.totalBeforeSize += message.beforeSize;
|
|
107
|
+
this.stats.totalAfterSize += message.afterSize;
|
|
108
|
+
|
|
109
|
+
// 触发进度更新回调
|
|
110
|
+
if (this.config.onProgress) {
|
|
111
|
+
this.config.onProgress({
|
|
112
|
+
completed: this.stats.completed,
|
|
113
|
+
failed: this.stats.failed,
|
|
114
|
+
total: this.stats.total,
|
|
115
|
+
beforeSize: message.beforeSize,
|
|
116
|
+
afterSize: message.afterSize,
|
|
117
|
+
filePath: message.filePath,
|
|
118
|
+
outputPath: message.outputPath
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 处理下一个任务
|
|
124
|
+
this.assignNextTask(worker);
|
|
125
|
+
} else if (message.type === "error") {
|
|
126
|
+
const task = this.activeTasks.get(message.taskId);
|
|
127
|
+
if (task) {
|
|
128
|
+
this.activeTasks.delete(message.taskId);
|
|
129
|
+
|
|
130
|
+
// 检查是否需要重试
|
|
131
|
+
if (!task.retried) {
|
|
132
|
+
task.retried = true;
|
|
133
|
+
this.taskQueue.unshift(task); // 重新加入队列头部
|
|
134
|
+
} else {
|
|
135
|
+
this.failedTasks.push({
|
|
136
|
+
...message,
|
|
137
|
+
task
|
|
138
|
+
});
|
|
139
|
+
this.stats.failed++;
|
|
140
|
+
|
|
141
|
+
if (this.config.onProgress) {
|
|
142
|
+
this.config.onProgress({
|
|
143
|
+
completed: this.stats.completed,
|
|
144
|
+
failed: this.stats.failed,
|
|
145
|
+
total: this.stats.total,
|
|
146
|
+
error: message.error,
|
|
147
|
+
filePath: message.filePath
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 处理下一个任务
|
|
154
|
+
this.assignNextTask(worker);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// 处理工作进程异常退出
|
|
159
|
+
handleWorkerExit(worker) {
|
|
160
|
+
// 移除已退出的进程
|
|
161
|
+
const index = this.workers.indexOf(worker);
|
|
162
|
+
if (index > -1) {
|
|
163
|
+
this.workers.splice(index, 1);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// 将该进程的未完成任务重新加入队列
|
|
167
|
+
for (const [taskId, task] of this.activeTasks.entries()) {
|
|
168
|
+
if (task.workerId === worker.pid) {
|
|
169
|
+
this.activeTasks.delete(taskId);
|
|
170
|
+
this.taskQueue.unshift(task);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// 启动新的替代进程
|
|
175
|
+
if (!this.isShuttingDown) {
|
|
176
|
+
this.startReplacementWorker();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// 启动替代进程
|
|
181
|
+
async startReplacementWorker() {
|
|
182
|
+
try {
|
|
183
|
+
const workerPath = path.join(__dirname, "worker.js");
|
|
184
|
+
const worker = fork(workerPath);
|
|
185
|
+
|
|
186
|
+
await new Promise((resolve, reject) => {
|
|
187
|
+
worker.on("message", (message) => {
|
|
188
|
+
if (message.type === "ready") {
|
|
189
|
+
this.workers.push(worker);
|
|
190
|
+
resolve();
|
|
191
|
+
|
|
192
|
+
// 为新进程分配任务
|
|
193
|
+
this.assignNextTask(worker);
|
|
194
|
+
} else {
|
|
195
|
+
this.handleWorkerMessage(worker, message);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
worker.on("error", reject);
|
|
200
|
+
worker.on("exit", (code) => {
|
|
201
|
+
if (!this.isShuttingDown && code !== 0) {
|
|
202
|
+
this.handleWorkerExit(worker);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
setTimeout(() => reject(new Error("替代进程启动超时")), 10000);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
console.log(`已启动替代进程`);
|
|
210
|
+
} catch (error) {
|
|
211
|
+
console.error(`启动替代进程失败:`, error);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// 添加任务到队列
|
|
216
|
+
addTasks(tasks) {
|
|
217
|
+
this.stats.total = tasks.length;
|
|
218
|
+
|
|
219
|
+
// 按文件大小降序排序(大文件优先)
|
|
220
|
+
tasks.sort((a, b) => b.size - a.size);
|
|
221
|
+
|
|
222
|
+
this.taskQueue.push(...tasks);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// 开始处理任务
|
|
226
|
+
start() {
|
|
227
|
+
// 为每个工作进程分配初始任务
|
|
228
|
+
for (const worker of this.workers) {
|
|
229
|
+
this.assignNextTask(worker);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// 为工作进程分配下一个任务
|
|
234
|
+
assignNextTask(worker) {
|
|
235
|
+
if (this.isShuttingDown || this.taskQueue.length === 0) {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const task = this.taskQueue.shift();
|
|
240
|
+
if (!task) {
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const taskId = this.nextTaskId++;
|
|
245
|
+
task.taskId = taskId;
|
|
246
|
+
task.workerId = worker.pid;
|
|
247
|
+
this.activeTasks.set(taskId, task);
|
|
248
|
+
|
|
249
|
+
worker.send({
|
|
250
|
+
type: "task",
|
|
251
|
+
taskId,
|
|
252
|
+
filePath: task.filePath,
|
|
253
|
+
config: {
|
|
254
|
+
quality: this.config.quality,
|
|
255
|
+
gifColours: this.config.gifColours,
|
|
256
|
+
outputDir: this.config.outputDir,
|
|
257
|
+
replace: this.config.replace
|
|
258
|
+
},
|
|
259
|
+
baseDir: this.config.baseDir
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// 等待所有任务完成
|
|
264
|
+
async waitForCompletion() {
|
|
265
|
+
return new Promise((resolve) => {
|
|
266
|
+
const checkInterval = setInterval(() => {
|
|
267
|
+
if (this.activeTasks.size === 0 && this.taskQueue.length === 0) {
|
|
268
|
+
clearInterval(checkInterval);
|
|
269
|
+
resolve();
|
|
270
|
+
}
|
|
271
|
+
}, 100);
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// 获取统计报告
|
|
276
|
+
getReport() {
|
|
277
|
+
const duration = Date.now() - this.startTime;
|
|
278
|
+
const durationSeconds = duration / 1000;
|
|
279
|
+
const speed = this.stats.completed / durationSeconds;
|
|
280
|
+
|
|
281
|
+
const savedSize = this.stats.totalBeforeSize - this.stats.totalAfterSize;
|
|
282
|
+
const compressionRate = this.stats.totalBeforeSize > 0
|
|
283
|
+
? ((savedSize / this.stats.totalBeforeSize) * 100).toFixed(1)
|
|
284
|
+
: 0;
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
total: this.stats.total,
|
|
288
|
+
completed: this.stats.completed,
|
|
289
|
+
failed: this.stats.failed,
|
|
290
|
+
duration: durationSeconds,
|
|
291
|
+
speed: speed.toFixed(1),
|
|
292
|
+
totalBeforeSize: this.stats.totalBeforeSize,
|
|
293
|
+
totalAfterSize: this.stats.totalAfterSize,
|
|
294
|
+
savedSize,
|
|
295
|
+
compressionRate,
|
|
296
|
+
workerCount: this.stats.workerCount,
|
|
297
|
+
failedTasks: this.failedTasks
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// 优雅关闭所有工作进程
|
|
302
|
+
async shutdown() {
|
|
303
|
+
this.isShuttingDown = true;
|
|
304
|
+
|
|
305
|
+
// 等待当前任务完成(最多30秒)
|
|
306
|
+
const timeout = new Promise((resolve) => setTimeout(resolve, 30000));
|
|
307
|
+
const completion = this.waitForCompletion();
|
|
308
|
+
|
|
309
|
+
await Promise.race([completion, timeout]);
|
|
310
|
+
|
|
311
|
+
// 发送关闭信号
|
|
312
|
+
for (const worker of this.workers) {
|
|
313
|
+
try {
|
|
314
|
+
worker.send({ type: "shutdown" });
|
|
315
|
+
} catch (error) {
|
|
316
|
+
// 忽略发送失败的错误
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// 等待进程退出
|
|
321
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
322
|
+
|
|
323
|
+
// 强制终止仍未退出的进程
|
|
324
|
+
for (const worker of this.workers) {
|
|
325
|
+
try {
|
|
326
|
+
worker.kill();
|
|
327
|
+
} catch (error) {
|
|
328
|
+
// 忽略终止失败的错误
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
this.workers = [];
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
module.exports = WorkerPool;
|
package/lib/worker.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const sharp = require("sharp");
|
|
4
|
+
|
|
5
|
+
// 获取文件大小(字节)
|
|
6
|
+
function getFileSize(filePath) {
|
|
7
|
+
try {
|
|
8
|
+
const stats = fs.statSync(filePath);
|
|
9
|
+
return stats.size;
|
|
10
|
+
} catch (error) {
|
|
11
|
+
return 0;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// 压缩单张图片
|
|
16
|
+
async function compressImage(filePath, config, baseDir) {
|
|
17
|
+
const beforeSize = getFileSize(filePath);
|
|
18
|
+
const startTime = Date.now();
|
|
19
|
+
|
|
20
|
+
sharp.cache(false);
|
|
21
|
+
|
|
22
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
23
|
+
let sharpInstance;
|
|
24
|
+
|
|
25
|
+
switch (ext) {
|
|
26
|
+
case ".png":
|
|
27
|
+
sharpInstance = sharp(filePath).png({ quality: config.quality });
|
|
28
|
+
break;
|
|
29
|
+
case ".jpg":
|
|
30
|
+
case ".jpeg":
|
|
31
|
+
sharpInstance = sharp(filePath).jpeg({ quality: config.quality });
|
|
32
|
+
break;
|
|
33
|
+
case ".gif":
|
|
34
|
+
sharpInstance = sharp(filePath, {
|
|
35
|
+
animated: true,
|
|
36
|
+
limitInputPixels: false,
|
|
37
|
+
}).gif({ colours: config.gifColours });
|
|
38
|
+
break;
|
|
39
|
+
default:
|
|
40
|
+
throw new Error(`不支持的文件类型: ${ext}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 原地替换模式或输出到新目录
|
|
44
|
+
let outputFilePath;
|
|
45
|
+
let outputFileDir;
|
|
46
|
+
|
|
47
|
+
if (config.replace) {
|
|
48
|
+
// 原地替换:使用临时文件,包含进程ID避免冲突
|
|
49
|
+
outputFilePath = filePath + `.tmp.${process.pid}`;
|
|
50
|
+
outputFileDir = path.dirname(outputFilePath);
|
|
51
|
+
} else {
|
|
52
|
+
// 保持目录结构输出到新目录
|
|
53
|
+
const relativePath = path.relative(baseDir, filePath);
|
|
54
|
+
outputFilePath = path.join(config.outputDir, relativePath);
|
|
55
|
+
outputFileDir = path.dirname(outputFilePath);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!fs.existsSync(outputFileDir)) {
|
|
59
|
+
fs.mkdirSync(outputFileDir, { recursive: true });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const buffer = await sharpInstance.toBuffer();
|
|
63
|
+
fs.writeFileSync(outputFilePath, buffer);
|
|
64
|
+
fs.chmodSync(outputFilePath, 0o646);
|
|
65
|
+
|
|
66
|
+
// 如果是原地替换模式,将临时文件重命名为原文件
|
|
67
|
+
if (config.replace) {
|
|
68
|
+
fs.unlinkSync(filePath);
|
|
69
|
+
fs.renameSync(outputFilePath, filePath);
|
|
70
|
+
outputFilePath = filePath;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const afterSize = getFileSize(outputFilePath);
|
|
74
|
+
const duration = Date.now() - startTime;
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
filePath,
|
|
78
|
+
outputPath: outputFilePath,
|
|
79
|
+
beforeSize,
|
|
80
|
+
afterSize,
|
|
81
|
+
duration
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 工作进程消息处理
|
|
86
|
+
process.on("message", async (message) => {
|
|
87
|
+
if (message.type === "task") {
|
|
88
|
+
const { taskId, filePath, config, baseDir } = message;
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const result = await compressImage(filePath, config, baseDir);
|
|
92
|
+
|
|
93
|
+
process.send({
|
|
94
|
+
type: "success",
|
|
95
|
+
taskId,
|
|
96
|
+
filePath: result.filePath,
|
|
97
|
+
outputPath: result.outputPath,
|
|
98
|
+
beforeSize: result.beforeSize,
|
|
99
|
+
afterSize: result.afterSize,
|
|
100
|
+
duration: result.duration
|
|
101
|
+
});
|
|
102
|
+
} catch (error) {
|
|
103
|
+
process.send({
|
|
104
|
+
type: "error",
|
|
105
|
+
taskId,
|
|
106
|
+
filePath,
|
|
107
|
+
error: error.message
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
} else if (message.type === "shutdown") {
|
|
111
|
+
process.exit(0);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// 通知主进程工作进程已就绪
|
|
116
|
+
process.send({ type: "ready", workerId: process.pid });
|
package/package.json
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "minify-pic-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0-beta.1",
|
|
4
4
|
"description": "一个简单易用的图片批量压缩命令行工具,支持 PNG、JPEG、GIF 格式,适合前端和设计师快速优化图片体积。",
|
|
5
5
|
"main": "index.js",
|
|
6
|
+
"files": [
|
|
7
|
+
"index.js",
|
|
8
|
+
"lib",
|
|
9
|
+
"README.md"
|
|
10
|
+
],
|
|
6
11
|
"bin": {
|
|
7
12
|
"mpic": "index.js"
|
|
8
13
|
},
|
|
9
14
|
"scripts": {
|
|
10
|
-
"build": "node ./index.js"
|
|
15
|
+
"build": "node ./index.js",
|
|
16
|
+
"test": "mpic -d ./test/src -o ./test/output -y"
|
|
11
17
|
},
|
|
12
18
|
"keywords": [
|
|
13
19
|
"图片压缩",
|