lubanpng 0.1.0 → 0.2.0
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/dist/api.js +5 -1
- package/dist/args.js +28 -1
- package/dist/commands/compress.js +68 -14
- package/dist/heic.js +29 -0
- package/dist/help.js +3 -1
- package/dist/run.js +2 -0
- package/package.json +1 -1
package/dist/api.js
CHANGED
|
@@ -89,9 +89,13 @@ export class ApiClient {
|
|
|
89
89
|
me(signal) {
|
|
90
90
|
return this.request("GET", "/v1/me", { signal });
|
|
91
91
|
}
|
|
92
|
-
async uploadImage(filePath, signal) {
|
|
92
|
+
async uploadImage(filePath, options = {}, signal) {
|
|
93
93
|
const data = await readFile(filePath);
|
|
94
94
|
const form = new FormData();
|
|
95
|
+
if (options.convert !== undefined)
|
|
96
|
+
form.append("convert", options.convert);
|
|
97
|
+
if (options.background !== undefined)
|
|
98
|
+
form.append("background", options.background);
|
|
95
99
|
form.append("file", new Blob([new Uint8Array(data)]), basename(filePath));
|
|
96
100
|
return this.request("POST", "/v1/images/compress", { formData: form, signal });
|
|
97
101
|
}
|
package/dist/args.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { UsageError } from "./errors.js";
|
|
2
|
+
export const TARGET_FORMATS = ["png", "jpeg", "webp", "avif"];
|
|
2
3
|
export const DEFAULT_CONCURRENCY = 4;
|
|
3
4
|
export const MAX_CONCURRENCY = 16;
|
|
4
|
-
const STRING_OPTIONS = new Set(["--api-base", "--out", "--concurrency"]);
|
|
5
|
+
const STRING_OPTIONS = new Set(["--api-base", "--out", "--concurrency", "--convert", "--background"]);
|
|
5
6
|
const BOOLEAN_OPTIONS = new Set(["--in-place", "--recursive", "--help", "-h", "--version", "-v"]);
|
|
6
7
|
const COMMANDS = new Set(["login", "logout", "compress", "usage"]);
|
|
7
8
|
const readConcurrency = (raw) => {
|
|
@@ -13,6 +14,20 @@ const readConcurrency = (raw) => {
|
|
|
13
14
|
}
|
|
14
15
|
return value;
|
|
15
16
|
};
|
|
17
|
+
const readTarget = (raw) => {
|
|
18
|
+
const lowered = raw.trim().toLowerCase();
|
|
19
|
+
const normalized = lowered === "jpg" ? "jpeg" : lowered;
|
|
20
|
+
if (!TARGET_FORMATS.includes(normalized)) {
|
|
21
|
+
throw new UsageError(`--convert 只支持 png、jpeg、webp、avif,收到:${raw}`);
|
|
22
|
+
}
|
|
23
|
+
return normalized;
|
|
24
|
+
};
|
|
25
|
+
const readBackground = (raw) => {
|
|
26
|
+
const match = /^#?([0-9a-fA-F]{6})$/.exec(raw.trim());
|
|
27
|
+
if (!match)
|
|
28
|
+
throw new UsageError(`--background 需要是 #RRGGBB 形式的颜色,收到:${raw}`);
|
|
29
|
+
return `#${match[1].toLowerCase()}`;
|
|
30
|
+
};
|
|
16
31
|
export const parseArgv = (argv) => {
|
|
17
32
|
const options = new Map();
|
|
18
33
|
const positionals = [];
|
|
@@ -66,9 +81,19 @@ export const parseArgv = (argv) => {
|
|
|
66
81
|
const inPlace = options.get("--in-place") === true;
|
|
67
82
|
const recursive = options.get("--recursive") === true;
|
|
68
83
|
const concurrency = options.get("--concurrency");
|
|
84
|
+
const convertRaw = options.get("--convert");
|
|
85
|
+
const backgroundRaw = options.get("--background");
|
|
69
86
|
if (inPlace && outValue !== undefined) {
|
|
70
87
|
throw new UsageError("--in-place 与 --out 不能同时使用");
|
|
71
88
|
}
|
|
89
|
+
const convert = typeof convertRaw === "string" ? readTarget(convertRaw) : undefined;
|
|
90
|
+
const background = typeof backgroundRaw === "string" ? readBackground(backgroundRaw) : undefined;
|
|
91
|
+
if (inPlace && convert !== undefined) {
|
|
92
|
+
throw new UsageError("--in-place 不能与 --convert 同时使用,转换结果请用 --out 或默认输出");
|
|
93
|
+
}
|
|
94
|
+
if (background !== undefined && convert === undefined) {
|
|
95
|
+
throw new UsageError("--background 需要与 --convert 一起使用");
|
|
96
|
+
}
|
|
72
97
|
const paths = positionals.slice(1);
|
|
73
98
|
if (paths.length === 0)
|
|
74
99
|
throw new UsageError("compress 至少需要一个文件或目录路径");
|
|
@@ -80,6 +105,8 @@ export const parseArgv = (argv) => {
|
|
|
80
105
|
inPlace,
|
|
81
106
|
recursive,
|
|
82
107
|
concurrency: typeof concurrency === "string" ? readConcurrency(concurrency) : DEFAULT_CONCURRENCY,
|
|
108
|
+
convert,
|
|
109
|
+
background,
|
|
83
110
|
};
|
|
84
111
|
}
|
|
85
112
|
if (positionals.length > 1) {
|
|
@@ -4,10 +4,27 @@ import { ApiClient, describeError, } from "../api.js";
|
|
|
4
4
|
import { API_KEY_ENV, resolveApiKey } from "../config.js";
|
|
5
5
|
import { UsageError } from "../errors.js";
|
|
6
6
|
import { formatBytes, formatSavings, formatSizePair, periodNoun, savingsPercent } from "../format.js";
|
|
7
|
-
|
|
7
|
+
import { HEIC_EXTENSIONS, HEIC_IN_PLACE_MESSAGE, isHeicPath, prepareHeicUpload } from "../heic.js";
|
|
8
|
+
const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ...HEIC_EXTENSIONS]);
|
|
9
|
+
const FORMAT_EXTENSIONS = { png: ".png", jpeg: ".jpg", gif: ".gif", webp: ".webp", avif: ".avif" };
|
|
10
|
+
const SUPPORTED_FORMATS_LABEL = "PNG / JPEG / GIF / WebP / AVIF,macOS 上另支持 HEIC";
|
|
8
11
|
const WAIT_SECONDS = 30;
|
|
9
12
|
const MAX_POLL_ROUNDS = 40;
|
|
10
13
|
const SIZE_COLUMN = 9;
|
|
14
|
+
const sameFamily = (extension, format) => {
|
|
15
|
+
const current = extension.toLowerCase();
|
|
16
|
+
const target = FORMAT_EXTENSIONS[format];
|
|
17
|
+
return target !== undefined && (current === target || (target === ".jpg" && current === ".jpeg"));
|
|
18
|
+
};
|
|
19
|
+
export const outputExtensionFor = (sourcePath, format) => {
|
|
20
|
+
const current = extname(sourcePath);
|
|
21
|
+
if (!format)
|
|
22
|
+
return current;
|
|
23
|
+
const target = FORMAT_EXTENSIONS[format];
|
|
24
|
+
if (target === undefined || sameFamily(current, format))
|
|
25
|
+
return current;
|
|
26
|
+
return target;
|
|
27
|
+
};
|
|
11
28
|
const walk = async (root, current, push) => {
|
|
12
29
|
const entries = await readdir(current, { withFileTypes: true });
|
|
13
30
|
for (const entry of entries) {
|
|
@@ -52,13 +69,18 @@ export const collectFiles = async (inputs, recursive) => {
|
|
|
52
69
|
}
|
|
53
70
|
return collected;
|
|
54
71
|
};
|
|
55
|
-
const
|
|
72
|
+
const withExtension = (path, extension) => {
|
|
73
|
+
const current = extname(path);
|
|
74
|
+
return `${path.slice(0, path.length - current.length)}${extension}`;
|
|
75
|
+
};
|
|
76
|
+
const outputPathFor = (file, options, format) => {
|
|
56
77
|
if (options.inPlace)
|
|
57
78
|
return file.path;
|
|
79
|
+
const extension = outputExtensionFor(file.path, format);
|
|
58
80
|
if (options.out !== undefined)
|
|
59
|
-
return join(options.out, file.relative);
|
|
81
|
+
return join(options.out, withExtension(file.relative, extension));
|
|
60
82
|
const ext = extname(file.path);
|
|
61
|
-
return `${file.path.slice(0, file.path.length - ext.length)}.min${
|
|
83
|
+
return `${file.path.slice(0, file.path.length - ext.length)}.min${extension}`;
|
|
62
84
|
};
|
|
63
85
|
const writeFileAtomic = async (target, bytes) => {
|
|
64
86
|
const temp = `${target}.lubanpng-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
@@ -74,7 +96,7 @@ const writeFileAtomic = async (target, bytes) => {
|
|
|
74
96
|
const assertNoTargetConflicts = (files, options) => {
|
|
75
97
|
const byTarget = new Map();
|
|
76
98
|
for (const file of files) {
|
|
77
|
-
const target = resolve(outputPathFor(file, options));
|
|
99
|
+
const target = resolve(outputPathFor(file, options, options.convert));
|
|
78
100
|
const sources = byTarget.get(target);
|
|
79
101
|
if (sources === undefined) {
|
|
80
102
|
byTarget.set(target, [file.path]);
|
|
@@ -126,7 +148,7 @@ export const compressCommand = async (context, options) => {
|
|
|
126
148
|
};
|
|
127
149
|
const files = await collectFiles(options.paths, options.recursive);
|
|
128
150
|
if (files.length === 0) {
|
|
129
|
-
throw new UsageError(
|
|
151
|
+
throw new UsageError(`没有找到可压缩的图片(支持 ${SUPPORTED_FORMATS_LABEL})`);
|
|
130
152
|
}
|
|
131
153
|
assertNoTargetConflicts(files, options);
|
|
132
154
|
const nameWidth = Math.max(...files.map((file) => file.name.length));
|
|
@@ -144,32 +166,61 @@ export const compressCommand = async (context, options) => {
|
|
|
144
166
|
return;
|
|
145
167
|
}
|
|
146
168
|
const percent = savingsPercent(outcome.originalSize, outcome.compressedSize);
|
|
147
|
-
|
|
169
|
+
const converted = outcome.converted === null ? "" : ` → ${outcome.converted}`;
|
|
170
|
+
context.io.write(` ${name} ${original} → ${compressed} ${formatSavings(percent)}${converted}\n`);
|
|
148
171
|
};
|
|
149
172
|
const processOne = async (file) => {
|
|
150
173
|
const originalSize = (await stat(file.path)).size;
|
|
174
|
+
const failure = (error) => ({
|
|
175
|
+
file,
|
|
176
|
+
ok: false,
|
|
177
|
+
retained: false,
|
|
178
|
+
converted: null,
|
|
179
|
+
originalSize,
|
|
180
|
+
compressedSize: 0,
|
|
181
|
+
error,
|
|
182
|
+
});
|
|
183
|
+
let prepared = { path: file.path, name: file.name, cleanup: async () => undefined };
|
|
184
|
+
if (isHeicPath(file.path)) {
|
|
185
|
+
if (options.inPlace)
|
|
186
|
+
return failure(HEIC_IN_PLACE_MESSAGE);
|
|
187
|
+
try {
|
|
188
|
+
prepared = await prepareHeicUpload(file.path);
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
return failure(error instanceof Error ? error.message : String(error));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
151
194
|
try {
|
|
152
|
-
const upload = await client.uploadImage(
|
|
195
|
+
const upload = await client.uploadImage(prepared.path, {
|
|
196
|
+
convert: options.convert,
|
|
197
|
+
background: options.background,
|
|
198
|
+
});
|
|
153
199
|
onQuota(upload.quota);
|
|
154
200
|
const view = await waitForCompletion(client, upload.data.task_id, onQuota);
|
|
155
201
|
if (view.status !== "completed" || view.compressed_url === null) {
|
|
156
|
-
return
|
|
202
|
+
return failure(view.error_msg ?? "压缩失败");
|
|
157
203
|
}
|
|
158
204
|
if (options.inPlace && view.compressed_size !== null && view.compressed_size >= originalSize) {
|
|
159
|
-
return { file, ok: true, retained: true, originalSize, compressedSize: view.compressed_size, error: null };
|
|
205
|
+
return { file, ok: true, retained: true, converted: null, originalSize, compressedSize: view.compressed_size, error: null };
|
|
160
206
|
}
|
|
161
207
|
const bytes = await client.download(view.compressed_url);
|
|
162
208
|
const compressedSize = view.compressed_size ?? bytes.byteLength;
|
|
163
209
|
if (options.inPlace && compressedSize >= originalSize) {
|
|
164
|
-
return { file, ok: true, retained: true, originalSize, compressedSize, error: null };
|
|
210
|
+
return { file, ok: true, retained: true, converted: null, originalSize, compressedSize, error: null };
|
|
165
211
|
}
|
|
166
|
-
const
|
|
212
|
+
const outputFormat = view.output_format ?? options.convert ?? null;
|
|
213
|
+
const target = outputPathFor(file, options, outputFormat);
|
|
167
214
|
await mkdir(dirname(target), { recursive: true });
|
|
168
215
|
await writeFileAtomic(target, bytes);
|
|
169
|
-
|
|
216
|
+
const converted = extname(target).toLowerCase() === extname(file.path).toLowerCase() ? null : basename(target);
|
|
217
|
+
return { file, ok: true, retained: false, converted, originalSize, compressedSize, error: null };
|
|
170
218
|
}
|
|
171
219
|
catch (error) {
|
|
172
|
-
return
|
|
220
|
+
return failure(describeError(error));
|
|
221
|
+
}
|
|
222
|
+
finally {
|
|
223
|
+
await prepared.cleanup();
|
|
173
224
|
}
|
|
174
225
|
};
|
|
175
226
|
const outcomes = [];
|
|
@@ -187,6 +238,9 @@ export const compressCommand = async (context, options) => {
|
|
|
187
238
|
`节省 ${formatBytes(saved)}`,
|
|
188
239
|
`${periodNoun(period)}剩余 ${remaining} 次`,
|
|
189
240
|
];
|
|
241
|
+
const converted = outcomes.filter((outcome) => outcome.converted !== null);
|
|
242
|
+
if (converted.length > 0)
|
|
243
|
+
parts.push(`${converted.length} 张已转换`);
|
|
190
244
|
if (retained.length > 0)
|
|
191
245
|
parts.push(`${retained.length} 张无收益保留原图`);
|
|
192
246
|
if (failed.length > 0)
|
package/dist/heic.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { basename, extname, join } from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
export const HEIC_EXTENSIONS = new Set([".heic", ".heif"]);
|
|
7
|
+
export const HEIC_UNSUPPORTED_MESSAGE = "HEIC 只能在 macOS 上由系统转换后上传,其他系统请先导出为 JPEG";
|
|
8
|
+
export const HEIC_IN_PLACE_MESSAGE = "HEIC 会转成 JPEG,不能就地覆盖,请用 --out 或默认输出";
|
|
9
|
+
const SIPS_JPEG_QUALITY = "92";
|
|
10
|
+
const runCommand = async (command, args) => {
|
|
11
|
+
await promisify(execFile)(command, args);
|
|
12
|
+
};
|
|
13
|
+
export const isHeicPath = (path) => HEIC_EXTENSIONS.has(extname(path).toLowerCase());
|
|
14
|
+
export const prepareHeicUpload = async (sourcePath, platform = process.platform, run = runCommand) => {
|
|
15
|
+
if (platform !== "darwin")
|
|
16
|
+
throw new Error(HEIC_UNSUPPORTED_MESSAGE);
|
|
17
|
+
const dir = await mkdtemp(join(tmpdir(), "lubanpng-heic-"));
|
|
18
|
+
const name = `${basename(sourcePath, extname(sourcePath))}.jpg`;
|
|
19
|
+
const target = join(dir, name);
|
|
20
|
+
const cleanup = () => rm(dir, { recursive: true, force: true });
|
|
21
|
+
try {
|
|
22
|
+
await run("sips", ["-s", "format", "jpeg", "-s", "formatOptions", SIPS_JPEG_QUALITY, sourcePath, "--out", target]);
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
await cleanup();
|
|
26
|
+
throw new Error(`HEIC 转换失败:${error instanceof Error ? error.message : String(error)}`);
|
|
27
|
+
}
|
|
28
|
+
return { path: target, name, cleanup };
|
|
29
|
+
};
|
package/dist/help.js
CHANGED
|
@@ -10,7 +10,7 @@ export const HELP = [
|
|
|
10
10
|
"命令:",
|
|
11
11
|
" login 粘贴 API Key,校验后保存到本机",
|
|
12
12
|
" logout 清除本机保存的 API Key",
|
|
13
|
-
" compress <路径...>
|
|
13
|
+
" compress <路径...> 压缩文件或目录,可选转换格式;macOS 上 HEIC 先由系统转成 JPEG",
|
|
14
14
|
" usage 查看套餐、本期用量与重置时间",
|
|
15
15
|
"",
|
|
16
16
|
"全局选项:",
|
|
@@ -23,6 +23,8 @@ export const HELP = [
|
|
|
23
23
|
" --in-place 覆盖原文件",
|
|
24
24
|
" --recursive 递归处理目录",
|
|
25
25
|
" --concurrency <n> 并发数(默认 4,最大 16)",
|
|
26
|
+
" --convert <fmt> 转换输出格式:png、jpeg、webp、avif(额外计 1 次)",
|
|
27
|
+
" --background <hex> 透明图转 JPEG 时的背景色,如 #ffffff",
|
|
26
28
|
"",
|
|
27
29
|
"环境变量:",
|
|
28
30
|
` ${API_KEY_ENV} API Key(优先于本机配置)`,
|
package/dist/run.js
CHANGED