lake-cimg 0.0.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 +138 -0
- package/bin/cimg.js +77 -0
- package/lib/compress.js +234 -0
- package/lib/constants.js +13 -0
- package/package.json +33 -0
package/README.md
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# lake-cimg · `cimg`
|
|
2
|
+
|
|
3
|
+
命令行批量压缩图片,默认输出 **WebP**;支持单文件、目录,以及递归子目录。基于 [sharp](https://sharp.pixelplumbing.com/),可多任务并行处理。
|
|
4
|
+
|
|
5
|
+
## 特性
|
|
6
|
+
|
|
7
|
+
- 默认转为 WebP,可调质量;可选 `--no-webp` 保留原格式仅压缩
|
|
8
|
+
- 可选按最长边缩放(`--size`),不指定则只做编码压缩、不改变像素尺寸
|
|
9
|
+
- 目录批量处理;`-r` 递归子目录;配合 `-o` 时保持相对目录结构
|
|
10
|
+
- 处理过程输出体积对比;失败时非零退出码
|
|
11
|
+
|
|
12
|
+
## 环境要求
|
|
13
|
+
|
|
14
|
+
- **Node.js** ≥ 18
|
|
15
|
+
|
|
16
|
+
## 安装
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
# 全局安装后,任意目录可直接使用 cimg
|
|
20
|
+
npm install -g lake-cimg
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# 在本项目目录内开发或本地使用时
|
|
25
|
+
cd /path/to/cimg
|
|
26
|
+
npm install
|
|
27
|
+
node bin/cimg.js --help
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
通过 `npx` 单次运行(无需全局安装):
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npx lake-cimg <路径> [选项]
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## 快速开始
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
# 查看帮助与版本
|
|
40
|
+
cimg --help
|
|
41
|
+
cimg --version
|
|
42
|
+
|
|
43
|
+
# 压缩当前目录下支持的图片(见下文「行为说明」:未指定 -o 时会替换源文件)
|
|
44
|
+
cimg .
|
|
45
|
+
|
|
46
|
+
# 输出到单独目录,并限制最长边 1200px
|
|
47
|
+
cimg ./photos -o ./dist -s 1200
|
|
48
|
+
|
|
49
|
+
# 递归子目录,质量 80
|
|
50
|
+
cimg ./assets -r -q 80
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
使用本地 `npm run` 时,**必须在参数前加 `--`**,否则 npm 会吞掉 `--size` 等选项:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
npm run compress -- ./photo.png --size 100
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## 命令说明
|
|
60
|
+
|
|
61
|
+
### 用法
|
|
62
|
+
|
|
63
|
+
```text
|
|
64
|
+
cimg [options] [input]
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`input` 为**文件或文件夹**路径(相对当前工作目录或绝对路径均可)。未传 `input` 或为空时会打印帮助并以状态码 `1` 退出。
|
|
68
|
+
|
|
69
|
+
### 选项
|
|
70
|
+
|
|
71
|
+
| 选项 | 说明 |
|
|
72
|
+
| --- | --- |
|
|
73
|
+
| `-o, --out-dir <dir>` | 输出根目录。指定后**不删除**源文件,结果写入该目录 |
|
|
74
|
+
| `-s, --size <px>` | 最长边像素上限(可选)。不指定则**不缩放**,仅压缩/转码 |
|
|
75
|
+
| `-q, --quality <1-100>` | WebP 质量,默认 `75` |
|
|
76
|
+
| `--no-webp` | 不转为 WebP,在原格式下压缩(jpeg/png/webp/gif 等) |
|
|
77
|
+
| `-r, --recursive` | 输入为目录时,递归处理子目录 |
|
|
78
|
+
| `-h, --help` | 显示帮助 |
|
|
79
|
+
| `-V, --version` | 显示版本 |
|
|
80
|
+
|
|
81
|
+
### 行为说明
|
|
82
|
+
|
|
83
|
+
**未指定 `-o` 时**:在源文件所在目录生成 `.webp`(或 `--no-webp` 时覆盖原格式文件),并**删除原始图片**。批量处理前请确认已备份或使用 `-o` 写到单独目录。
|
|
84
|
+
|
|
85
|
+
**指定 `-o` 且使用 `-r`**:会按源目录的相对路径在输出目录下重建子文件夹,避免不同子目录中的同名文件互相覆盖。
|
|
86
|
+
|
|
87
|
+
**并发**:按 CPU 核心数限制并行任务数,大目录下可显著快于单线程。
|
|
88
|
+
|
|
89
|
+
## 支持格式
|
|
90
|
+
|
|
91
|
+
输入:**jpg / jpeg、png、webp、gif**(gif 含动图,处理时由 sharp 按能力读写)。
|
|
92
|
+
|
|
93
|
+
输出:默认 **WebP**;`--no-webp` 时尽量保持原容器格式并压缩。
|
|
94
|
+
|
|
95
|
+
## 退出码
|
|
96
|
+
|
|
97
|
+
| 码 | 含义 |
|
|
98
|
+
| --- | --- |
|
|
99
|
+
| `0` | 全部成功,或没有可处理文件(目录下无匹配图片时也会以 `0` 退出并提示) |
|
|
100
|
+
| `1` | 存在处理失败、参数非法、输入路径不存在等错误 |
|
|
101
|
+
|
|
102
|
+
## 在代码中调用
|
|
103
|
+
|
|
104
|
+
包入口为 `lib/compress.js`(ESM),可导入例如:
|
|
105
|
+
|
|
106
|
+
- `run(options)` — 批量入口(与 CLI 行为一致)
|
|
107
|
+
- `processOne(inputPath, options)` — 单文件
|
|
108
|
+
- `collectFiles(inputPath, recursive)` — 枚举待处理路径
|
|
109
|
+
|
|
110
|
+
适合在构建脚本或 Node 服务中复用同一套逻辑。
|
|
111
|
+
|
|
112
|
+
## 常见问题
|
|
113
|
+
|
|
114
|
+
- **写权限**:`-o` 指向的目录需可创建/写入;否则 sharp 或 `fs` 会报错。
|
|
115
|
+
- **路径**:Windows 与 Unix 路径均可;建议对含空格的路径加引号。
|
|
116
|
+
- **质量范围**:`--quality` 须为 **1–100** 的整数;`--size` 若提供则须为 **正整数**。
|
|
117
|
+
|
|
118
|
+
## 发布到 npm(维护者)
|
|
119
|
+
|
|
120
|
+
仓库通过 [`.github/workflows/publish-npm.yml`](.github/workflows/publish-npm.yml) 在发布 GitHub Release 或手动触发 workflow 时执行 `npm publish --provenance`。
|
|
121
|
+
|
|
122
|
+
### 本地自检(可选)
|
|
123
|
+
|
|
124
|
+
在项目根目录执行:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
npm pkg fix
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
会按 npm 建议整理 `package.json`(例如 `repository` 等字段格式)。执行后用 `git diff` 查看变更,确认无误再提交。
|
|
131
|
+
|
|
132
|
+
### GitHub Actions Secrets
|
|
133
|
+
|
|
134
|
+
在 GitHub 仓库 **Settings → Secrets and variables → Actions** 中配置 **`NPM_TOKEN`**:使用 [npm](https://www.npmjs.com/) 帐号的 **Automation** 类型 token(或具备发布权限的 token),与 workflow 里 `NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}` 对应。未配置或 token 无效时,发布步骤会在认证阶段失败。
|
|
135
|
+
|
|
136
|
+
## 依赖说明
|
|
137
|
+
|
|
138
|
+
核心库:[sharp](https://sharp.pixelplumbing.com/)、[commander](https://github.com/tj/commander.js)。项目自身许可证以仓库根目录声明为准(若有)。
|
package/bin/cimg.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* CLI entry: parse args with Commander, validate, then run lib/compress.run().
|
|
4
|
+
*/
|
|
5
|
+
import { readFileSync } from "fs";
|
|
6
|
+
import { dirname, join, resolve } from "path";
|
|
7
|
+
import { fileURLToPath } from "url";
|
|
8
|
+
import { program } from "commander";
|
|
9
|
+
import { run } from "../lib/compress.js";
|
|
10
|
+
|
|
11
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const pkg = JSON.parse(
|
|
13
|
+
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {{ quality: number, size?: number | null }} opts
|
|
18
|
+
* @returns {{ ok: true } | { ok: false, message: string }}
|
|
19
|
+
*/
|
|
20
|
+
function validateCliOptions(opts) {
|
|
21
|
+
const { quality, size } = opts;
|
|
22
|
+
if (Number.isNaN(quality) || quality < 1 || quality > 100) {
|
|
23
|
+
return { ok: false, message: "错误: --quality 须为 1–100 的整数" };
|
|
24
|
+
}
|
|
25
|
+
if (size != null && (Number.isNaN(size) || size < 1)) {
|
|
26
|
+
return { ok: false, message: "错误: --size 须为正整数" };
|
|
27
|
+
}
|
|
28
|
+
return { ok: true };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Resolve a user path relative to cwd. */
|
|
32
|
+
function resolveUserPath(relativeOrAbsolute) {
|
|
33
|
+
return resolve(process.cwd(), relativeOrAbsolute.trim());
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
program
|
|
37
|
+
.name("cimg")
|
|
38
|
+
.description("批量压缩图片为 WebP,支持单文件或文件夹")
|
|
39
|
+
.version(pkg.version)
|
|
40
|
+
.argument("[input]", "文件或文件夹路径")
|
|
41
|
+
.option("-o, --out-dir <dir>", "输出目录(不指定则直接修改源文件:同目录生成 .webp 后删除原图)")
|
|
42
|
+
.option("-s, --size <px>", "最大边长(可选,不指定则只压缩不缩放)", (v) => parseInt(v, 10))
|
|
43
|
+
.option("-q, --quality <1-100>", "WebP 质量", (v) => parseInt(v, 10), 75)
|
|
44
|
+
.option("--no-webp", "不转为 WebP,保留原格式仅压缩(默认会转为 WebP)")
|
|
45
|
+
.option("-r, --recursive", "递归处理子目录")
|
|
46
|
+
.action(async (input, opts) => {
|
|
47
|
+
if (!input || input.trim() === "") {
|
|
48
|
+
program.outputHelp();
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
const validation = validateCliOptions({
|
|
52
|
+
quality: opts.quality,
|
|
53
|
+
size: opts.size,
|
|
54
|
+
});
|
|
55
|
+
if (!validation.ok) {
|
|
56
|
+
console.error(validation.message);
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
const inputPath = resolveUserPath(input);
|
|
60
|
+
const outDir = opts.outDir ? resolveUserPath(opts.outDir) : null;
|
|
61
|
+
try {
|
|
62
|
+
const { failed } = await run({
|
|
63
|
+
inputPath,
|
|
64
|
+
outDir,
|
|
65
|
+
size: opts.size ?? null,
|
|
66
|
+
quality: opts.quality,
|
|
67
|
+
toWebp: opts.webp !== false,
|
|
68
|
+
recursive: !!opts.recursive,
|
|
69
|
+
});
|
|
70
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
71
|
+
} catch (err) {
|
|
72
|
+
console.error("错误:", err.message);
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
program.parse();
|
package/lib/compress.js
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core compression logic: collect files, process one, run batch.
|
|
3
|
+
* No process.argv dependency — suitable for programmatic use and tests.
|
|
4
|
+
*/
|
|
5
|
+
import { readdir, readFile, writeFile, mkdir, stat } from "fs/promises";
|
|
6
|
+
import { join, extname, dirname, resolve, basename, relative } from "path";
|
|
7
|
+
import { cpus } from "os";
|
|
8
|
+
import sharp from "sharp";
|
|
9
|
+
import {
|
|
10
|
+
SUPPORTED_EXT,
|
|
11
|
+
DEFAULT_QUALITY,
|
|
12
|
+
DEFAULT_EFFORT,
|
|
13
|
+
supportedFormatsLabel,
|
|
14
|
+
} from "./constants.js";
|
|
15
|
+
|
|
16
|
+
/** Max concurrent tasks for processing images */
|
|
17
|
+
const MAX_CONCURRENCY = Math.max(1, cpus().length);
|
|
18
|
+
|
|
19
|
+
function formatSize(bytes) {
|
|
20
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
21
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
22
|
+
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function formatCompare(before, after) {
|
|
26
|
+
const saved = before - after;
|
|
27
|
+
const pct = before > 0 ? ((saved / before) * 100).toFixed(1) : "0";
|
|
28
|
+
return `${formatSize(before)} → ${formatSize(after)}(节省 ${pct}%)`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Run tasks with a maximum concurrency limit.
|
|
33
|
+
* @param {Array<() => Promise<any>>} tasks
|
|
34
|
+
* @param {number} concurrency
|
|
35
|
+
* @returns {Promise<{status: string, value?: any, reason?: any}[]>}
|
|
36
|
+
*/
|
|
37
|
+
async function runWithConcurrency(tasks, concurrency) {
|
|
38
|
+
const results = new Array(tasks.length);
|
|
39
|
+
let currentIndex = 0;
|
|
40
|
+
|
|
41
|
+
async function worker() {
|
|
42
|
+
while (currentIndex < tasks.length) {
|
|
43
|
+
const index = currentIndex++;
|
|
44
|
+
try {
|
|
45
|
+
const val = await tasks[index]();
|
|
46
|
+
results[index] = { status: "fulfilled", value: val };
|
|
47
|
+
} catch (err) {
|
|
48
|
+
results[index] = { status: "rejected", reason: err };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const workers = Array.from({ length: Math.min(concurrency, tasks.length) }, worker);
|
|
54
|
+
await Promise.all(workers);
|
|
55
|
+
return results;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isSupported(filePath) {
|
|
59
|
+
return SUPPORTED_EXT.includes(extname(filePath).toLowerCase());
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Encode pipeline to WebP buffer with shared sharp options.
|
|
64
|
+
* @param {import("sharp").Sharp} pipeline
|
|
65
|
+
* @param {number} quality
|
|
66
|
+
* @param {{ lossless?: boolean }} [options]
|
|
67
|
+
*/
|
|
68
|
+
async function encodeWebpBuffer(pipeline, quality, { lossless = false } = {}) {
|
|
69
|
+
return pipeline
|
|
70
|
+
.webp({
|
|
71
|
+
quality,
|
|
72
|
+
effort: DEFAULT_EFFORT,
|
|
73
|
+
smartSubsample: true,
|
|
74
|
+
lossless,
|
|
75
|
+
})
|
|
76
|
+
.toBuffer();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Recursively collect image paths under dir (or return [dir] if dir is a file).
|
|
81
|
+
* @param {string} inputPath - Absolute path to file or directory
|
|
82
|
+
* @param {boolean} recursive - Whether to recurse into subdirectories
|
|
83
|
+
* @returns {Promise<string[]>} Absolute paths to supported image files
|
|
84
|
+
*/
|
|
85
|
+
export async function collectFiles(inputPath, recursive = false) {
|
|
86
|
+
const s = await stat(inputPath);
|
|
87
|
+
if (s.isFile()) {
|
|
88
|
+
if (!isSupported(inputPath)) return [];
|
|
89
|
+
return [resolve(inputPath)];
|
|
90
|
+
}
|
|
91
|
+
if (s.isDirectory()) {
|
|
92
|
+
const names = await readdir(inputPath, { withFileTypes: true });
|
|
93
|
+
const promises = names.map(async (ent) => {
|
|
94
|
+
const full = join(inputPath, ent.name);
|
|
95
|
+
if (ent.isDirectory() && recursive) {
|
|
96
|
+
return collectFiles(full, true);
|
|
97
|
+
} else if (ent.isFile() && isSupported(ent.name)) {
|
|
98
|
+
return [resolve(full)];
|
|
99
|
+
}
|
|
100
|
+
return [];
|
|
101
|
+
});
|
|
102
|
+
const subArrays = await Promise.all(promises);
|
|
103
|
+
return subArrays.flat();
|
|
104
|
+
}
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Compress one image. Output WebP by default, or keep original format when toWebp is false.
|
|
110
|
+
* @param {string} inputPath - Absolute path to input image
|
|
111
|
+
* @param {{ outDir?: string | null, size?: number | null, quality?: number, toWebp?: boolean }} options
|
|
112
|
+
* @returns {Promise<{ outPath: string, sizeBefore: number, sizeAfter: number }>}
|
|
113
|
+
*/
|
|
114
|
+
export async function processOne(inputPath, options = {}) {
|
|
115
|
+
const { outDir, size, quality = DEFAULT_QUALITY, toWebp = true } = options;
|
|
116
|
+
const baseDir = dirname(inputPath);
|
|
117
|
+
const extRaw = extname(inputPath);
|
|
118
|
+
const ext = extRaw.toLowerCase();
|
|
119
|
+
const baseName = basename(inputPath, extRaw);
|
|
120
|
+
const outBase = outDir ? resolve(outDir) : baseDir;
|
|
121
|
+
const outExt = toWebp ? ".webp" : ext;
|
|
122
|
+
const outPath = join(outBase, `${baseName}${outExt}`);
|
|
123
|
+
|
|
124
|
+
const inputBuffer = await readFile(inputPath);
|
|
125
|
+
const sizeBefore = inputBuffer.length;
|
|
126
|
+
let pipeline = sharp(inputBuffer, { animated: true });
|
|
127
|
+
|
|
128
|
+
if (size != null && size > 0) {
|
|
129
|
+
pipeline = pipeline.resize({
|
|
130
|
+
width: size,
|
|
131
|
+
height: size,
|
|
132
|
+
fit: "inside",
|
|
133
|
+
withoutEnlargement: true,
|
|
134
|
+
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const outputBuffer = toWebp
|
|
139
|
+
? await encodeWebpBuffer(pipeline, quality, { lossless: false })
|
|
140
|
+
: await toFormatBuffer(pipeline, ext, quality);
|
|
141
|
+
|
|
142
|
+
const sizeAfter = outputBuffer.length;
|
|
143
|
+
await mkdir(outBase, { recursive: true });
|
|
144
|
+
await writeFile(outPath, outputBuffer);
|
|
145
|
+
return { outPath, sizeBefore, sizeAfter };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Encode to original format (jpeg/png/webp/gif) for --no-webp.
|
|
150
|
+
*/
|
|
151
|
+
async function toFormatBuffer(pipeline, ext, quality) {
|
|
152
|
+
switch (ext) {
|
|
153
|
+
case ".jpg":
|
|
154
|
+
case ".jpeg":
|
|
155
|
+
return pipeline.jpeg({ quality, mozjpeg: true }).toBuffer();
|
|
156
|
+
case ".png":
|
|
157
|
+
return pipeline.png({ compressionLevel: 9 }).toBuffer();
|
|
158
|
+
case ".webp":
|
|
159
|
+
return encodeWebpBuffer(pipeline, quality);
|
|
160
|
+
case ".gif":
|
|
161
|
+
return pipeline.gif().toBuffer();
|
|
162
|
+
default:
|
|
163
|
+
return encodeWebpBuffer(pipeline, quality);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Run batch compression.
|
|
169
|
+
* @param {{ inputPath: string, outDir?: string | null, size?: number | null, quality?: number, recursive?: boolean }} options
|
|
170
|
+
* @returns {Promise<{ success: number, failed: number }>} success/failed counts; throws if input invalid
|
|
171
|
+
*/
|
|
172
|
+
export async function run(options) {
|
|
173
|
+
const {
|
|
174
|
+
inputPath,
|
|
175
|
+
outDir,
|
|
176
|
+
size,
|
|
177
|
+
quality,
|
|
178
|
+
toWebp = true,
|
|
179
|
+
recursive = false,
|
|
180
|
+
} = options;
|
|
181
|
+
const absoluteInput = resolve(inputPath);
|
|
182
|
+
|
|
183
|
+
let inputStat;
|
|
184
|
+
try {
|
|
185
|
+
inputStat = await stat(absoluteInput);
|
|
186
|
+
} catch {
|
|
187
|
+
throw new Error(`输入路径不存在: ${absoluteInput}`);
|
|
188
|
+
}
|
|
189
|
+
const baseDir = inputStat.isDirectory() ? absoluteInput : dirname(absoluteInput);
|
|
190
|
+
|
|
191
|
+
const files = await collectFiles(absoluteInput, recursive);
|
|
192
|
+
if (files.length === 0) {
|
|
193
|
+
console.log(`未找到支持的图片文件(支持: ${supportedFormatsLabel()})`);
|
|
194
|
+
return { success: 0, failed: 0 };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
console.log(`\n共 ${files.length} 个文件,开始压缩…\n`);
|
|
198
|
+
|
|
199
|
+
const tasks = files.map((fp) => async () => {
|
|
200
|
+
let targetOutDir = outDir;
|
|
201
|
+
if (outDir && recursive) {
|
|
202
|
+
const relPath = relative(baseDir, dirname(fp));
|
|
203
|
+
targetOutDir = join(outDir, relPath);
|
|
204
|
+
}
|
|
205
|
+
return processOne(fp, { outDir: targetOutDir, size, quality, toWebp });
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const results = await runWithConcurrency(tasks, MAX_CONCURRENCY);
|
|
209
|
+
|
|
210
|
+
let success = 0;
|
|
211
|
+
let failed = 0;
|
|
212
|
+
let totalBefore = 0;
|
|
213
|
+
let totalAfter = 0;
|
|
214
|
+
for (let i = 0; i < files.length; i++) {
|
|
215
|
+
const r = results[i];
|
|
216
|
+
const fp = files[i];
|
|
217
|
+
if (r.status === "fulfilled") {
|
|
218
|
+
const { outPath, sizeBefore, sizeAfter } = r.value;
|
|
219
|
+
totalBefore += sizeBefore;
|
|
220
|
+
totalAfter += sizeAfter;
|
|
221
|
+
console.log(` ✅ ${fp} → ${basename(outPath)}`);
|
|
222
|
+
console.log(` ${formatCompare(sizeBefore, sizeAfter)}`);
|
|
223
|
+
success++;
|
|
224
|
+
} else {
|
|
225
|
+
console.error(` ❌ ${fp}: ${r.reason?.message ?? r.reason}`);
|
|
226
|
+
failed++;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
console.log(`\n完成:成功 ${success},失败 ${failed}。`);
|
|
230
|
+
if (success > 0 && totalBefore > 0) {
|
|
231
|
+
console.log(`合计:${formatCompare(totalBefore, totalAfter)}`);
|
|
232
|
+
}
|
|
233
|
+
return { success, failed };
|
|
234
|
+
}
|
package/lib/constants.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Supported image extensions for compression */
|
|
2
|
+
export const SUPPORTED_EXT = [".jpg", ".jpeg", ".png", ".webp", ".gif"];
|
|
3
|
+
|
|
4
|
+
/** Default WebP quality (1–100) */
|
|
5
|
+
export const DEFAULT_QUALITY = 75;
|
|
6
|
+
|
|
7
|
+
/** Sharp WebP effort (0–6), 6 = smallest size */
|
|
8
|
+
export const DEFAULT_EFFORT = 6;
|
|
9
|
+
|
|
10
|
+
/** Human-readable list for messages (e.g. "jpg, jpeg, png, webp, gif") */
|
|
11
|
+
export function supportedFormatsLabel() {
|
|
12
|
+
return SUPPORTED_EXT.map((e) => e.slice(1)).join(", ");
|
|
13
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "lake-cimg",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Batch image compression to WebP — single file or folder",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/lake0090/lake-cimg.git"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"main": "lib/compress.js",
|
|
11
|
+
"bin": {
|
|
12
|
+
"cimg": "bin/cimg.js"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"bin",
|
|
16
|
+
"lib",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=18"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"compress": "node bin/cimg.js",
|
|
27
|
+
"start": "node bin/cimg.js"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"commander": "^12.0.0",
|
|
31
|
+
"sharp": "^0.33.0"
|
|
32
|
+
}
|
|
33
|
+
}
|