minify-pic-cli 1.1.1 → 1.2.0-beta.2

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/index.js CHANGED
@@ -1,13 +1,16 @@
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 = {
10
- targetDir: process.cwd(), // 需要压缩的目录(可包含子目录)
13
+ targetDirs: [process.cwd()], // 需要压缩的目录数组(可包含子目录)
11
14
  outputDir: path.join(process.cwd(), "output"), // 输出目录
12
15
  quality: 80, // 压缩质量 0 - 100
13
16
  gifColours: 128, // GIF调色板最大数量
@@ -15,20 +18,68 @@ const DEFAULT_CONFIG = {
15
18
  };
16
19
 
17
20
  // 询问用户是否继续
18
- function askUserToContinue() {
21
+ function askUserToContinue(dirStats) {
19
22
  return new Promise((resolve) => {
20
23
  const rl = readline.createInterface({
21
24
  input: process.stdin,
22
25
  output: process.stdout,
23
26
  });
24
- console.log(`当前目录路径为: ${process.cwd()}`);
25
- rl.question("是否需要压缩当前目录的所有图片?Y/N:", (answer) => {
27
+
28
+ if (dirStats && dirStats.length > 0) {
29
+ console.log("\n检测到以下目录待压缩:");
30
+ dirStats.forEach((stat, index) => {
31
+ console.log(` ${index + 1}. ${stat.dir} (${stat.count} 张图片)`);
32
+ });
33
+ const totalCount = dirStats.reduce((sum, stat) => sum + stat.count, 0);
34
+ console.log(`总计: ${dirStats.length} 个目录, ${totalCount} 张图片\n`);
35
+ } else {
36
+ console.log(`当前目录路径为: ${process.cwd()}`);
37
+ }
38
+
39
+ rl.question("是否继续压缩? Y/N:", (answer) => {
26
40
  rl.close();
27
41
  resolve(answer.trim().toLowerCase() === "y");
28
42
  });
29
43
  });
30
44
  }
31
45
 
46
+ // 验证目录有效性
47
+ function validateDirectories(dirs) {
48
+ const validDirs = [];
49
+ const invalidDirs = [];
50
+
51
+ for (const dir of dirs) {
52
+ try {
53
+ if (!fs.existsSync(dir)) {
54
+ console.warn(`警告: 目录不存在,跳过: ${dir}`);
55
+ invalidDirs.push({ dir, reason: '目录不存在' });
56
+ continue;
57
+ }
58
+
59
+ const stat = fs.statSync(dir);
60
+ if (!stat.isDirectory()) {
61
+ console.warn(`警告: 路径不是目录,跳过: ${dir}`);
62
+ invalidDirs.push({ dir, reason: '不是目录' });
63
+ continue;
64
+ }
65
+
66
+ // 检查读取权限
67
+ try {
68
+ fs.accessSync(dir, fs.constants.R_OK);
69
+ validDirs.push(dir);
70
+ } catch (err) {
71
+ console.error(`错误: 目录无读取权限,跳过: ${dir}`);
72
+ invalidDirs.push({ dir, reason: '无读取权限' });
73
+ }
74
+ } catch (err) {
75
+ console.error(`错误: 验证目录失败,跳过: ${dir}`, err.message);
76
+ invalidDirs.push({ dir, reason: err.message });
77
+ }
78
+ }
79
+
80
+ return { validDirs, invalidDirs };
81
+ }
82
+
32
83
  // 获取文件大小(带单位,自动转换MB/KB)
33
84
  function getFileSizeWithUnit(filePath) {
34
85
  const stats = fs.statSync(filePath);
@@ -46,7 +97,7 @@ function getFileSizeWithUnit(filePath) {
46
97
  }
47
98
 
48
99
  // 压缩单张图片
49
- async function compressImage(filePath, config, baseDir = config.targetDir) {
100
+ async function compressImage(filePath, config, sourceDir) {
50
101
  const beforeSize = getFileSizeWithUnit(filePath);
51
102
  sharp.cache(false);
52
103
 
@@ -81,8 +132,10 @@ async function compressImage(filePath, config, baseDir = config.targetDir) {
81
132
  outputFileDir = path.dirname(outputFilePath);
82
133
  } else {
83
134
  // 保持目录结构输出到新目录
84
- const relativePath = path.relative(baseDir, filePath);
85
- outputFilePath = path.join(config.outputDir, relativePath);
135
+ // 多目录支持: 使用sourceDir的basename作为一级子目录
136
+ const relativePath = path.relative(sourceDir, filePath);
137
+ const sourceDirBasename = path.basename(sourceDir);
138
+ outputFilePath = path.join(config.outputDir, sourceDirBasename, relativePath);
86
139
  outputFileDir = path.dirname(outputFilePath);
87
140
  }
88
141
 
@@ -117,9 +170,10 @@ async function compressImage(filePath, config, baseDir = config.targetDir) {
117
170
  }
118
171
  }
119
172
 
120
- // 递归压缩目录下所有图片
121
- async function compressFiles(dir, config, baseDir = config.targetDir) {
173
+ // 递归扫描目录,收集所有图片文件
174
+ function collectImageFiles(dir, config, sourceDir) {
122
175
  const files = fs.readdirSync(dir);
176
+ const imageFiles = [];
123
177
 
124
178
  for (const file of files) {
125
179
  const filePath = path.join(dir, file);
@@ -133,13 +187,180 @@ async function compressFiles(dir, config, baseDir = config.targetDir) {
133
187
 
134
188
  if (stat.isDirectory()) {
135
189
  if (!config.blackDirs.includes(file)) {
136
- await compressFiles(filePath, config, baseDir);
190
+ const subFiles = collectImageFiles(filePath, config, sourceDir);
191
+ imageFiles.push(...subFiles);
137
192
  } else {
138
193
  console.log(`跳过的目录: ${filePath}`);
139
194
  }
140
195
  } else if (/\.(png|jpe?g|gif)$/i.test(filePath)) {
141
- await compressImage(filePath, config, baseDir);
196
+ imageFiles.push({
197
+ filePath,
198
+ size: stat.size,
199
+ sourceDir: sourceDir // 记录所属源目录
200
+ });
201
+ }
202
+ }
203
+
204
+ return imageFiles;
205
+ }
206
+
207
+ // 收集多个目录的图片文件
208
+ function collectImageFilesFromMultipleDirs(dirs, config) {
209
+ const allImageFiles = [];
210
+ const dirStats = [];
211
+
212
+ for (const dir of dirs) {
213
+ console.log(`正在扫描目录: ${dir}`);
214
+
215
+ // 如果是非原地替换模式,跳过与输出目录相同的源目录
216
+ if (!config.replace && path.resolve(dir) === path.resolve(config.outputDir)) {
217
+ console.warn(`警告: 源目录与输出目录相同,跳过: ${dir}`);
218
+ continue;
219
+ }
220
+
221
+ const imageFiles = collectImageFiles(dir, config, dir);
222
+ allImageFiles.push(...imageFiles);
223
+ dirStats.push({
224
+ dir: dir,
225
+ count: imageFiles.length
226
+ });
227
+ console.log(` 找到 ${imageFiles.length} 张图片`);
228
+ }
229
+
230
+ return { allImageFiles, dirStats };
231
+ }
232
+
233
+ // 递归压缩目录下所有图片(单进程模式)
234
+ async function compressFiles(dir, config, sourceDir) {
235
+ const files = fs.readdirSync(dir);
236
+
237
+ for (const file of files) {
238
+ const filePath = path.join(dir, file);
239
+ const stat = fs.statSync(filePath);
240
+
241
+ // 跳过 output 目录(非原地替换模式)
242
+ if (!config.replace && filePath === config.outputDir) {
243
+ console.log(`跳过的目录: ${filePath}(为输出目录)`);
244
+ continue;
245
+ }
246
+
247
+ if (stat.isDirectory()) {
248
+ if (!config.blackDirs.includes(file)) {
249
+ await compressFiles(filePath, config, sourceDir);
250
+ } else {
251
+ console.log(`跳过的目录: ${filePath}`);
252
+ }
253
+ } else if (/\.(png|jpe?g|gif)$/i.test(filePath)) {
254
+ await compressImage(filePath, config, sourceDir);
255
+ }
256
+ }
257
+ }
258
+
259
+ // 多进程压缩
260
+ async function compressFilesParallel(imageFiles, config, setGlobalPool) {
261
+ const workerCount = WorkerPool.calculateWorkerCount(
262
+ imageFiles.length,
263
+ config.workers
264
+ );
265
+
266
+ // 如果计算结果是0或用户指定不使用并行,使用单进程模式
267
+ if (workerCount === 0 || config.noParallel) {
268
+ console.log(`使用单进程模式压缩 ${imageFiles.length} 张图片`);
269
+ const progress = new ProgressDisplay(imageFiles.length);
270
+ progress.setMultiProcess(false);
271
+
272
+ const startTime = Date.now();
273
+ let completed = 0;
274
+ let failed = 0;
275
+ let totalBeforeSize = 0;
276
+ let totalAfterSize = 0;
277
+
278
+ for (const fileInfo of imageFiles) {
279
+ try {
280
+ const beforeSize = fileInfo.size;
281
+ await compressImage(fileInfo.filePath, config, fileInfo.sourceDir);
282
+ const afterSize = fs.statSync(
283
+ config.replace ? fileInfo.filePath :
284
+ path.join(config.outputDir, path.basename(fileInfo.sourceDir), path.relative(fileInfo.sourceDir, fileInfo.filePath))
285
+ ).size;
286
+
287
+ completed++;
288
+ totalBeforeSize += beforeSize;
289
+ totalAfterSize += afterSize;
290
+ } catch (error) {
291
+ failed++;
292
+ console.error(`压缩出错: ${fileInfo.filePath}`, error.message);
293
+ }
142
294
  }
295
+
296
+ const duration = (Date.now() - startTime) / 1000;
297
+ const speed = completed / duration;
298
+ const savedSize = totalBeforeSize - totalAfterSize;
299
+ const compressionRate = totalBeforeSize > 0
300
+ ? ((savedSize / totalBeforeSize) * 100).toFixed(1)
301
+ : 0;
302
+
303
+ progress.showReport({
304
+ total: imageFiles.length,
305
+ completed,
306
+ failed,
307
+ duration,
308
+ speed: speed.toFixed(1),
309
+ totalBeforeSize,
310
+ totalAfterSize,
311
+ savedSize,
312
+ compressionRate,
313
+ workerCount: 0,
314
+ failedTasks: []
315
+ });
316
+
317
+ return;
318
+ }
319
+
320
+ // 多进程模式
321
+ console.log(`检测到 ${imageFiles.length} 张图片需要压缩`);
322
+ console.log(`将使用 ${workerCount} 个进程并行处理以加快速度`);
323
+
324
+ const progress = new ProgressDisplay(imageFiles.length);
325
+ progress.setMultiProcess(true);
326
+
327
+ const pool = new WorkerPool({
328
+ quality: config.quality,
329
+ gifColours: config.gifColours,
330
+ outputDir: config.outputDir,
331
+ replace: config.replace,
332
+ onProgress: (data) => {
333
+ progress.update(data);
334
+ }
335
+ });
336
+
337
+ // 设置全局引用,用于优雅退出
338
+ if (setGlobalPool) {
339
+ setGlobalPool(pool);
340
+ }
341
+
342
+ try {
343
+ await pool.initialize(workerCount);
344
+ pool.addTasks(imageFiles);
345
+ pool.start();
346
+ await pool.waitForCompletion();
347
+
348
+ // 获取最终统计数据
349
+ const report = pool.getReport();
350
+
351
+ // 更新并显示最终进度
352
+ progress.completed = report.completed;
353
+ progress.failed = report.failed;
354
+ progress.totalBeforeSize = report.totalBeforeSize;
355
+ progress.totalAfterSize = report.totalAfterSize;
356
+ progress.complete();
357
+
358
+ progress.showReport(report);
359
+ } catch (error) {
360
+ console.error("多进程压缩失败:", error);
361
+ throw error;
362
+ } finally {
363
+ await pool.shutdown();
143
364
  }
144
365
  }
145
366
 
@@ -149,46 +370,117 @@ const program = new Command();
149
370
  program
150
371
  .name("mpic")
151
372
  .description("图片批量压缩工具")
152
- .option("-d, --dir <dir>", "需要压缩的目录", DEFAULT_CONFIG.targetDir)
373
+ .option("-d, --dir <dir>", "需要压缩的目录(逗号分隔多个目录)", val => val.split(",").map(d => d.trim()), DEFAULT_CONFIG.targetDirs)
153
374
  .option("-o, --output <output>", "输出目录", DEFAULT_CONFIG.outputDir)
154
375
  .option("-q, --quality <quality>", "压缩质量(0-100)", String(DEFAULT_CONFIG.quality))
155
376
  .option("-g, --gif-colours <colours>", "GIF调色板最大数量(2-256)", String(DEFAULT_CONFIG.gifColours))
156
377
  .option("-b, --black-dirs <dirs>", "排除的子文件夹名称(逗号分隔)", val => val.split(","), DEFAULT_CONFIG.blackDirs)
157
378
  .option("-y, --yes", "跳过确认,直接开始压缩")
158
379
  .option("-r, --replace", "原地压缩替换原文件,不输出到新目录")
380
+ .option("-w, --workers <num>", "指定并发工作进程数")
381
+ .option("--no-parallel", "禁用多进程,使用单进程模式")
159
382
  .version(require('./package.json').version, '-v, --version', '显示版本号')
160
383
  .action(async (options) => {
161
- // 合并配置
384
+ // 全局工作进程池引用,用于优雅退出
385
+ let globalPool = null;
386
+
387
+ // 优雅退出处理
388
+ const gracefulShutdown = async (signal) => {
389
+ console.log(`\n\n接收到 ${signal} 信号,正在优雅退出...`);
390
+
391
+ if (globalPool) {
392
+ console.log("正在等待当前任务完成...");
393
+ try {
394
+ await globalPool.shutdown();
395
+ console.log("已安全关闭所有工作进程");
396
+ } catch (error) {
397
+ console.error("关闭工作进程时出错:", error);
398
+ }
399
+ }
400
+
401
+ console.log("程序已退出");
402
+ process.exit(0);
403
+ };
404
+
405
+ // 注册信号处理
406
+ process.on('SIGINT', () => gracefulShutdown('SIGINT'));
407
+ process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
408
+
409
+ // 合并配置 - 处理多目录
410
+ const inputDirs = Array.isArray(options.dir)
411
+ ? options.dir.map(d => path.resolve(process.cwd(), d))
412
+ : [path.resolve(process.cwd(), options.dir)];
413
+
162
414
  const config = {
163
- targetDir: path.resolve(process.cwd(), options.dir),
415
+ targetDirs: inputDirs,
164
416
  outputDir: options.replace ? null : path.resolve(process.cwd(), options.output),
165
417
  quality: parseInt(options.quality, 10),
166
418
  gifColours: parseInt(options.gifColours, 10),
167
419
  blackDirs: Array.isArray(options.blackDirs) ? options.blackDirs : [options.blackDirs],
168
420
  replace: options.replace || false,
421
+ workers: options.workers,
422
+ noParallel: options.noParallel || false,
169
423
  };
424
+
425
+ // 验证目录
426
+ console.log("正在验证目录...");
427
+ const { validDirs, invalidDirs } = validateDirectories(config.targetDirs);
428
+
429
+ if (invalidDirs.length > 0) {
430
+ console.log(`\n跳过了 ${invalidDirs.length} 个无效目录`);
431
+ }
432
+
433
+ if (validDirs.length === 0) {
434
+ console.error("错误: 所有目录都无效,程序退出");
435
+ process.exit(1);
436
+ }
437
+
438
+ console.log(`\n有效目录: ${validDirs.length} 个`);
439
+
440
+ // 收集所有图片文件
441
+ console.log("\n正在扫描图片文件...");
442
+ const { allImageFiles, dirStats } = collectImageFilesFromMultipleDirs(validDirs, config);
443
+
444
+ if (allImageFiles.length === 0) {
445
+ console.log("未找到需要压缩的图片文件");
446
+ process.exit(0);
447
+ }
170
448
 
171
449
  let shouldContinue = options.yes;
172
450
  if (!shouldContinue) {
173
- shouldContinue = await askUserToContinue();
451
+ shouldContinue = await askUserToContinue(dirStats);
174
452
  }
175
453
  if (!shouldContinue) {
176
454
  console.log("已取消操作。");
177
455
  process.exit(0);
178
456
  }
179
457
 
180
- const inputDir = config.targetDir;
181
- compressFiles(inputDir, config, inputDir)
182
- .then(() => {
183
- if (config.replace) {
184
- console.log("压缩任务全部完成,已原地替换所有图片");
185
- } else {
186
- console.log("压缩任务全部完成,已输出至", config.outputDir);
187
- }
188
- })
189
- .catch((err) => {
190
- console.error("压缩文件时出错:", err);
458
+ try {
459
+ await compressFilesParallel(allImageFiles, config, (pool) => {
460
+ globalPool = pool;
191
461
  });
462
+
463
+ // 解除全局引用
464
+ globalPool = null;
465
+
466
+ if (config.replace) {
467
+ console.log("\n压缩任务全部完成,已原地替换所有图片");
468
+ } else {
469
+ console.log("\n输出目录:", config.outputDir);
470
+ }
471
+
472
+ // 正常退出
473
+ process.exit(0);
474
+ } catch (err) {
475
+ console.error("压缩文件时出错:", err);
476
+
477
+ // 确保清理工作进程
478
+ if (globalPool) {
479
+ await globalPool.shutdown();
480
+ }
481
+
482
+ process.exit(1);
483
+ }
192
484
  });
193
485
 
194
486
  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
+ sourceDir: task.sourceDir,
254
+ config: {
255
+ quality: this.config.quality,
256
+ gifColours: this.config.gifColours,
257
+ outputDir: this.config.outputDir,
258
+ replace: this.config.replace
259
+ }
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,118 @@
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, sourceDir) {
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
+ // 多目录支持: 使用sourceDir的basename作为一级子目录
54
+ const relativePath = path.relative(sourceDir, filePath);
55
+ const sourceDirBasename = path.basename(sourceDir);
56
+ outputFilePath = path.join(config.outputDir, sourceDirBasename, relativePath);
57
+ outputFileDir = path.dirname(outputFilePath);
58
+ }
59
+
60
+ if (!fs.existsSync(outputFileDir)) {
61
+ fs.mkdirSync(outputFileDir, { recursive: true });
62
+ }
63
+
64
+ const buffer = await sharpInstance.toBuffer();
65
+ fs.writeFileSync(outputFilePath, buffer);
66
+ fs.chmodSync(outputFilePath, 0o646);
67
+
68
+ // 如果是原地替换模式,将临时文件重命名为原文件
69
+ if (config.replace) {
70
+ fs.unlinkSync(filePath);
71
+ fs.renameSync(outputFilePath, filePath);
72
+ outputFilePath = filePath;
73
+ }
74
+
75
+ const afterSize = getFileSize(outputFilePath);
76
+ const duration = Date.now() - startTime;
77
+
78
+ return {
79
+ filePath,
80
+ outputPath: outputFilePath,
81
+ beforeSize,
82
+ afterSize,
83
+ duration
84
+ };
85
+ }
86
+
87
+ // 工作进程消息处理
88
+ process.on("message", async (message) => {
89
+ if (message.type === "task") {
90
+ const { taskId, filePath, config, sourceDir } = message;
91
+
92
+ try {
93
+ const result = await compressImage(filePath, config, sourceDir);
94
+
95
+ process.send({
96
+ type: "success",
97
+ taskId,
98
+ filePath: result.filePath,
99
+ outputPath: result.outputPath,
100
+ beforeSize: result.beforeSize,
101
+ afterSize: result.afterSize,
102
+ duration: result.duration
103
+ });
104
+ } catch (error) {
105
+ process.send({
106
+ type: "error",
107
+ taskId,
108
+ filePath,
109
+ error: error.message
110
+ });
111
+ }
112
+ } else if (message.type === "shutdown") {
113
+ process.exit(0);
114
+ }
115
+ });
116
+
117
+ // 通知主进程工作进程已就绪
118
+ 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.1.1",
3
+ "version": "1.2.0-beta.2",
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/assets,./test/src/imgs -o ./test/output -y -w 2"
11
17
  },
12
18
  "keywords": [
13
19
  "图片压缩",