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 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
- const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif"]);
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 outputPathFor = (file, options) => {
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${ext}`;
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("没有找到可压缩的图片(支持 PNG / JPEG / GIF)");
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
- context.io.write(` ${name} ${original} → ${compressed} ${formatSavings(percent)}\n`);
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(file.path);
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 { file, ok: false, retained: false, originalSize, compressedSize: 0, error: view.error_msg ?? "压缩失败" };
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 target = outputPathFor(file, options);
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
- return { file, ok: true, retained: false, originalSize, compressedSize, error: null };
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 { file, ok: false, retained: false, originalSize, compressedSize: 0, error: describeError(error) };
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
@@ -41,6 +41,8 @@ export const run = async (argv, options = {}) => {
41
41
  inPlace: parsed.inPlace,
42
42
  recursive: parsed.recursive,
43
43
  concurrency: parsed.concurrency,
44
+ convert: parsed.convert,
45
+ background: parsed.background,
44
46
  });
45
47
  }
46
48
  return 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lubanpng",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "LubanPNG command line image compressor",
5
5
  "type": "module",
6
6
  "bin": {