siuuu 1.0.0 → 1.1.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/README.md CHANGED
@@ -13,28 +13,37 @@ npx siuuu <files or directories...> [options]
13
13
  Options:
14
14
 
15
15
  - `-o, --out <filename>` — output filename for the input right before it (repeatable)
16
- - `-d, --out-dir <dir>` — output directory (default: `siuuu-output/` in the current directory)
17
- - `-f, --format <format>` — output format: `png` / `jpeg` / `webp` (default: same as input)
16
+ - `-d, --out-dir <dir>` — output directory (default: `siuuu/`; use `.` for the current directory)
17
+ - `-f, --format <format>` — output format: `png` / `jpeg` / `webp`; repeatable to emit several at once (default: same as input)
18
+ - `-r, --recursive` — recurse into subdirectories for directory inputs
18
19
  - `-h, --help` — show help
19
20
  - `-v, --version` — show version
20
21
 
21
- Inputs without an explicit `-o` are written to the output directory under their original name; existing files are never overwritten (falls back to `name (n)`). With `-f`, every output is converted to that format; `-o` only sets the filename, not the format.
22
+ Same-name outputs are never overwritten they fall back to `name (n)`.
22
23
 
23
24
  ## Examples
24
25
 
25
26
  ```bash
26
- # Compress one or more files (default: ./siuuu-output/, original names)
27
- npx siuuu a.png b.jpg c.webp
27
+ # Keep each input's original format (default output dir: siuuu/)
28
+ npx siuuu a.png b.jpg c.webp # → siuuu/a.png, siuuu/b.jpg, siuuu/c.webp
28
29
 
29
- # Recursively compress all images in a directory
30
- npx siuuu photos/
30
+ # Compress a folder, recursing into subfolders (omit -r for top level only)
31
+ npx siuuu photos/ -r # → siuuu/<each image>, names flattened, original format
31
32
 
32
- # Convert everything to WebP
33
- npx siuuu photos/ -f webp
33
+ # Convert a directory to WebP and write it to a custom dir
34
+ npx siuuu photos/ -f webp -d dist/ # → dist/<each image>.webp
34
35
 
35
- # Per-file output names (-o binds to the input before it)
36
- npx siuuu a.png -o x.png b.png -o y.png
36
+ # Emit several formats at once (with -o, the extension follows each -f)
37
+ npx siuuu a.png -o pic -f png -f webp # → siuuu/pic.png, siuuu/pic.webp
37
38
 
38
- # Custom output directory
39
- npx siuuu imgs/ -d dist/
39
+ # Per-file output names (-o is a name inside the output dir)
40
+ npx siuuu a.png -o x.png b.png -o y.png # → siuuu/x.png, siuuu/y.png
41
+ ```
42
+
43
+ ## Skill
44
+
45
+ Install the agent [skill](https://skills.sh) for the `siuuu` CLI:
46
+
47
+ ```bash
48
+ npx skills add WBBB0730/siuuu
40
49
  ```
package/dist/cli.mjs CHANGED
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { t as compress } from "./src-CqREwc6F.mjs";
2
+ import { r as t, t as compress } from "./src-DYId9YLF.mjs";
3
3
  import { createRequire } from "node:module";
4
4
  import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
5
5
  import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
6
6
  import { parseArgs } from "node:util";
7
+ import pc from "picocolors";
7
8
  import * as os from "node:os";
8
9
  import { Worker } from "node:worker_threads";
9
10
  //#region src/batch.ts
@@ -45,8 +46,8 @@ function isInside(file, dir) {
45
46
  const rel = relative(resolve(dir), resolve(file));
46
47
  return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
47
48
  }
48
- async function listImages(dir) {
49
- return (await readdir(dir, { recursive: true })).filter((name) => isImage(name)).map((name) => join(dir, name)).sort();
49
+ async function listImages(dir, recursive = false) {
50
+ return (await readdir(dir, { recursive })).filter((name) => isImage(name)).map((name) => join(dir, name)).sort();
50
51
  }
51
52
  const workerUrl = new URL(import.meta.url.endsWith(".ts") ? "./worker.ts" : "./worker.mjs", import.meta.url);
52
53
  function compressOnWorker(worker, task) {
@@ -66,7 +67,7 @@ function compressOnWorker(worker, task) {
66
67
  };
67
68
  const onExit = (code) => {
68
69
  cleanup();
69
- reject(/* @__PURE__ */ new Error(`worker 异常退出(code ${code})`));
70
+ reject(new Error(t("workerExit", { code })));
70
71
  };
71
72
  worker.on("message", onMessage);
72
73
  worker.on("error", onError);
@@ -142,43 +143,41 @@ async function runBatch(jobs, handlers) {
142
143
  await Promise.all(Array.from({ length: concurrency }, consume));
143
144
  await writes.catch(() => {});
144
145
  const leftover = jobs.length - done.size;
145
- if (leftover > 0) console.warn(`并行压缩不可用,已回退主线程串行处理 ${leftover} 张`);
146
+ if (leftover > 0) console.warn(t("parallelFallback", { count: leftover }));
146
147
  }
147
148
  await runOnMain();
148
149
  }
149
150
  //#endregion
150
151
  //#region src/cli.ts
151
152
  const pkg = createRequire(import.meta.url)("../package.json");
152
- const DEFAULT_OUT_DIR = "siuuu-output";
153
- const HELP = `${pkg.name} v${pkg.version} — 压缩 PNG / JPEG / WebP 图片
154
-
155
- 用法:
156
- npx ${pkg.name} <文件或目录...> [选项]
157
-
158
- 选项:
159
- -o, --out <文件名> 为紧邻的前一个输入单独指定输出文件名(可多次)
160
- -d, --out-dir <目录> 全局输出目录(默认:当前目录下的 ${DEFAULT_OUT_DIR}/)
161
- -f, --format <格式> 输出格式 png | jpeg | webp(默认与输入一致,不做转换)
162
- -h, --help 显示帮助
163
- -v, --version 显示版本
164
-
165
- 说明:
166
- 传入目录时会递归压缩其中所有 PNG / JPEG / WebP,多文件时自动用多线程并行。
167
- 未用 -o 单独命名的,按原文件名输出到输出目录;同名不覆盖,自动改用「名称 (n)」。
168
- 指定 -f 后所有输出都转为该格式;-o 只决定文件名,不影响格式。
169
- 压缩参数:PNG 高画质(量化 ≤200 色 + oxipng)、JPEG 中画质(mozjpeg 75)、WebP 高画质(libwebp 90)。
170
-
171
- 示例:
172
- npx ${pkg.name} a.png b.jpg # 压缩到 ${DEFAULT_OUT_DIR}/a.png、${DEFAULT_OUT_DIR}/b.jpg
173
- npx ${pkg.name} photos/ -f webp # 递归压缩 photos/ 并全部转 webp
174
- npx ${pkg.name} a.png -o x.png b.png -o y.png # 分别指定输出文件名
175
- npx ${pkg.name} imgs/ -d dist/ # 输出到 dist/
176
- `;
153
+ const DEFAULT_OUT_DIR = "siuuu";
154
+ function help() {
155
+ return t("help", {
156
+ name: pkg.name,
157
+ version: pkg.version,
158
+ dir: DEFAULT_OUT_DIR
159
+ });
160
+ }
177
161
  function parseFormat(value) {
178
162
  const v = value.toLowerCase();
179
163
  if (v === "png" || v === "jpeg" || v === "webp") return v;
180
164
  if (v === "jpg") return "jpeg";
181
165
  }
166
+ function withExt(p, ext) {
167
+ return join(dirname(p), `${basename(p, extname(p))}.${ext}`);
168
+ }
169
+ const NAME_MAX = 32;
170
+ function displayName(p) {
171
+ const name = basename(p);
172
+ if (name.length <= NAME_MAX) return name;
173
+ const ext = extname(name);
174
+ const stem = basename(name, ext);
175
+ const budget = NAME_MAX - ext.length - 1;
176
+ if (budget < 4) return `${name.slice(0, NAME_MAX - 1)}…`;
177
+ const head = Math.ceil(budget / 2);
178
+ const tail = budget - head;
179
+ return `${stem.slice(0, head)}…${stem.slice(stem.length - tail)}${ext}`;
180
+ }
182
181
  async function uniqueOutPath(dir, filename, used) {
183
182
  const ext = extname(filename);
184
183
  const base = basename(filename, ext);
@@ -209,7 +208,12 @@ async function main() {
209
208
  },
210
209
  "format": {
211
210
  type: "string",
212
- short: "f"
211
+ short: "f",
212
+ multiple: true
213
+ },
214
+ "recursive": {
215
+ type: "boolean",
216
+ short: "r"
213
217
  },
214
218
  "help": {
215
219
  type: "boolean",
@@ -222,8 +226,8 @@ async function main() {
222
226
  }
223
227
  });
224
228
  } catch (error) {
225
- console.error(`参数错误:${error.message}\n`);
226
- process.stdout.write(HELP);
229
+ console.error(`${t("argError", { message: error.message })}\n`);
230
+ process.stdout.write(`${help()}\n`);
227
231
  process.exitCode = 1;
228
232
  return;
229
233
  }
@@ -233,18 +237,19 @@ async function main() {
233
237
  return;
234
238
  }
235
239
  if (values.help || positionals.length === 0) {
236
- process.stdout.write(HELP);
240
+ process.stdout.write(`${help()}\n`);
237
241
  if (positionals.length === 0 && !values.help) process.exitCode = 1;
238
242
  return;
239
243
  }
240
- let format;
241
- if (values.format !== void 0) {
242
- format = parseFormat(values.format);
243
- if (!format) {
244
- console.error(`错误:不支持的格式「${values.format}」,可选 png / jpeg / webp`);
244
+ const formats = [];
245
+ for (const raw of values.format ?? []) {
246
+ const f = parseFormat(raw);
247
+ if (!f) {
248
+ console.error(t("unsupportedFormat", { format: raw }));
245
249
  process.exitCode = 1;
246
250
  return;
247
251
  }
252
+ if (!formats.includes(f)) formats.push(f);
248
253
  }
249
254
  const outDir = values["out-dir"] ?? DEFAULT_OUT_DIR;
250
255
  const specs = [];
@@ -252,47 +257,80 @@ async function main() {
252
257
  else if (token.kind === "option" && token.name === "out") {
253
258
  const last = specs[specs.length - 1];
254
259
  if (!last) {
255
- console.error("错误:-o 必须跟在某个输入文件之后");
260
+ console.error(t("outMustFollowInput"));
256
261
  process.exitCode = 1;
257
262
  return;
258
263
  }
259
264
  last.output = token.value;
260
265
  }
261
266
  const jobs = [];
267
+ const pushJobs = (source, output) => {
268
+ if (formats.length === 0) jobs.push({
269
+ source,
270
+ output
271
+ });
272
+ else for (const f of formats) jobs.push({
273
+ source,
274
+ format: f,
275
+ output
276
+ });
277
+ };
262
278
  for (const spec of specs) if (await isDirectory(spec.input)) {
263
279
  if (spec.output !== void 0) {
264
- console.error(`错误:目录输入 ${spec.input} 不能用 -o 指定单个输出文件名`);
280
+ console.error(t("dirNoOut", { input: spec.input }));
265
281
  process.exitCode = 1;
266
282
  return;
267
283
  }
268
- for (const file of await listImages(spec.input)) if (!isInside(file, resolve(outDir))) jobs.push({
269
- source: file,
270
- format
271
- });
272
- } else jobs.push({
273
- source: spec.input,
274
- format,
275
- output: spec.output
276
- });
284
+ for (const file of await listImages(spec.input, values.recursive ?? false)) if (!isInside(file, resolve(outDir))) pushJobs(file);
285
+ } else pushJobs(spec.input, spec.output);
277
286
  if (jobs.length === 0) {
278
- console.error("没有找到可压缩的图片");
287
+ console.error(t("noImages"));
279
288
  process.exitCode = 1;
280
289
  return;
281
290
  }
291
+ process.stdout.write(`\n${pc.bold(pkg.name)} ${pc.dim(`v${pkg.version}`)}\n\n`);
282
292
  const used = /* @__PURE__ */ new Set();
283
- let failed = 0;
293
+ const nameWidth = jobs.reduce((m, j) => Math.max(m, displayName(j.source).length), 0);
294
+ let okCount = 0;
295
+ let failCount = 0;
296
+ let totalBefore = 0;
297
+ let totalAfter = 0;
284
298
  await runBatch(jobs, {
285
- resolveOutPath: (job, fmt) => job.output ?? uniqueOutPath(outDir, `${basename(job.source, extname(job.source))}.${OUTPUT_EXTENSION[fmt]}`, used),
299
+ resolveOutPath: (job, fmt) => {
300
+ if (job.output !== void 0) return join(outDir, job.format !== void 0 ? withExt(job.output, OUTPUT_EXTENSION[fmt]) : job.output);
301
+ return uniqueOutPath(outDir, `${basename(job.source, extname(job.source))}.${OUTPUT_EXTENSION[fmt]}`, used);
302
+ },
286
303
  onWritten: (job, { before, after, outPath }) => {
304
+ okCount++;
305
+ totalBefore += before;
306
+ totalAfter += after;
287
307
  const ratio = before > 0 ? Math.round((1 - after / before) * 100) : 0;
288
- console.log(`${job.source} ${formatSize(before)} → ${formatSize(after)} (-${ratio}%) → ${outPath}`);
308
+ const src = displayName(job.source);
309
+ const out = displayName(outPath);
310
+ const srcPad = " ".repeat(Math.max(0, nameWidth - src.length));
311
+ const outPad = " ".repeat(Math.max(0, nameWidth - out.length));
312
+ const sizes = `${formatSize(before)} ${pc.dim("→")} ${formatSize(after)}`;
313
+ const ratioText = ratio >= 0 ? pc.green(`-${ratio}%`) : pc.yellow(`+${-ratio}%`);
314
+ console.log(` ${pc.green("✓")} ${src}${srcPad} ${pc.dim("→")} ${pc.cyan(out)}${outPad} ${sizes} ${ratioText}`);
289
315
  },
290
316
  onFailed: (job, message) => {
291
- failed++;
292
- console.error(`${job.source} 失败:${message}`);
317
+ failCount++;
318
+ const src = displayName(job.source);
319
+ const srcPad = " ".repeat(Math.max(0, nameWidth - src.length));
320
+ console.error(` ${pc.red("✗")} ${src}${srcPad} ${pc.red(message)}`);
293
321
  }
294
322
  });
295
- if (failed > 0) process.exitCode = 1;
323
+ const percent = totalBefore > 0 ? Math.round((1 - totalAfter / totalBefore) * 100) : 0;
324
+ const summary = t("summary", {
325
+ files: jobs.length,
326
+ ok: pc.green(String(okCount)),
327
+ failed: failCount > 0 ? pc.red(String(failCount)) : "0",
328
+ percent,
329
+ before: formatSize(totalBefore),
330
+ after: formatSize(totalAfter)
331
+ });
332
+ console.log(`\n ${summary}`);
333
+ if (failCount > 0) process.exitCode = 1;
296
334
  }
297
335
  main();
298
336
  //#endregion
package/dist/index.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { n as detectFormat, r as PRESETS, t as compress } from "./src-CqREwc6F.mjs";
1
+ import { i as PRESETS, n as detectFormat, t as compress } from "./src-DYId9YLF.mjs";
2
2
  export { PRESETS, compress, detectFormat };
@@ -0,0 +1,245 @@
1
+ import { createRequire } from "node:module";
2
+ import { readFile } from "node:fs/promises";
3
+ import jpegDecode, { init } from "@jsquash/jpeg/decode.js";
4
+ import jpegEncode, { init as init$1 } from "@jsquash/jpeg/encode.js";
5
+ import pngDecode, { init as init$2 } from "@jsquash/png/decode.js";
6
+ import webpDecode, { init as init$3 } from "@jsquash/webp/decode.js";
7
+ import webpEncode, { init as init$4 } from "@jsquash/webp/encode.js";
8
+ import oxipngOptimise, { init as init$5 } from "@jsquash/oxipng/optimise.js";
9
+ import * as imagequant from "imagequant/imagequant_bg.js";
10
+ import { execSync } from "node:child_process";
11
+ import i18next from "i18next";
12
+ //#region src/codecs.ts
13
+ const PRESETS = {
14
+ png: {
15
+ maxColors: 200,
16
+ oxipngLevel: 2
17
+ },
18
+ jpeg: { quality: 75 },
19
+ webp: {
20
+ quality: 90,
21
+ method: 6,
22
+ lossless: 0
23
+ }
24
+ };
25
+ const require = createRequire(import.meta.url);
26
+ async function wasmBytes(spec) {
27
+ return new Uint8Array(await readFile(require.resolve(spec)));
28
+ }
29
+ async function wasmModule(spec) {
30
+ return WebAssembly.compile(await wasmBytes(spec));
31
+ }
32
+ let jpegDecodeReady;
33
+ let jpegEncodeReady;
34
+ let pngDecodeReady;
35
+ let webpDecodeReady;
36
+ let webpEncodeReady;
37
+ let oxipngReady;
38
+ let imagequantReady;
39
+ async function ensureImagequant() {
40
+ imagequantReady ??= (async () => {
41
+ const { instance } = await WebAssembly.instantiate(await wasmBytes("imagequant/imagequant_bg.wasm"), { "./imagequant_bg.js": imagequant });
42
+ imagequant.__wbg_set_wasm(instance.exports);
43
+ })();
44
+ return imagequantReady;
45
+ }
46
+ function asBuffer(data) {
47
+ return data;
48
+ }
49
+ function asImageData(image) {
50
+ return image;
51
+ }
52
+ function toUint8(data) {
53
+ return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
54
+ }
55
+ async function decode(data, format) {
56
+ switch (format) {
57
+ case "png":
58
+ pngDecodeReady ??= init$2(await wasmBytes("@jsquash/png/codec/pkg/squoosh_png_bg.wasm"));
59
+ await pngDecodeReady;
60
+ return pngDecode(asBuffer(data));
61
+ case "jpeg":
62
+ jpegDecodeReady ??= init(await wasmModule("@jsquash/jpeg/codec/dec/mozjpeg_dec.wasm"));
63
+ await jpegDecodeReady;
64
+ return jpegDecode(asBuffer(data));
65
+ case "webp":
66
+ webpDecodeReady ??= init$3(await wasmModule("@jsquash/webp/codec/dec/webp_dec.wasm"));
67
+ await webpDecodeReady;
68
+ return webpDecode(asBuffer(data));
69
+ }
70
+ }
71
+ async function encodePng(image) {
72
+ await ensureImagequant();
73
+ const source = imagequant.Imagequant.new_image(toUint8(image.data), image.width, image.height, 0);
74
+ const quantizer = new imagequant.Imagequant();
75
+ quantizer.set_max_colors(PRESETS.png.maxColors);
76
+ const quantized = quantizer.process(source);
77
+ oxipngReady ??= init$5(await wasmBytes("@jsquash/oxipng/codec/pkg/squoosh_oxipng_bg.wasm"));
78
+ await oxipngReady;
79
+ const optimised = await oxipngOptimise(asBuffer(quantized), {
80
+ level: PRESETS.png.oxipngLevel,
81
+ interlace: false
82
+ });
83
+ return new Uint8Array(optimised);
84
+ }
85
+ async function encodeJpeg(image) {
86
+ jpegEncodeReady ??= init$1(await wasmModule("@jsquash/jpeg/codec/enc/mozjpeg_enc.wasm"));
87
+ await jpegEncodeReady;
88
+ return new Uint8Array(await jpegEncode(asImageData(image), { quality: PRESETS.jpeg.quality }));
89
+ }
90
+ async function encodeWebp(image) {
91
+ webpEncodeReady ??= init$4(await wasmModule("@jsquash/webp/codec/enc/webp_enc_simd.wasm"));
92
+ await webpEncodeReady;
93
+ return new Uint8Array(await webpEncode(asImageData(image), { ...PRESETS.webp }));
94
+ }
95
+ async function encode(image, format) {
96
+ switch (format) {
97
+ case "png": return encodePng(image);
98
+ case "jpeg": return encodeJpeg(image);
99
+ case "webp": return encodeWebp(image);
100
+ }
101
+ }
102
+ //#endregion
103
+ //#region src/i18n.ts
104
+ function run(cmd) {
105
+ try {
106
+ return execSync(cmd, {
107
+ encoding: "utf8",
108
+ stdio: [
109
+ "ignore",
110
+ "pipe",
111
+ "ignore"
112
+ ],
113
+ timeout: 1e3
114
+ }).trim();
115
+ } catch {
116
+ return "";
117
+ }
118
+ }
119
+ function systemLocale() {
120
+ if (process.platform === "darwin") {
121
+ const matched = run("defaults read -g AppleLanguages").match(/[A-Za-z]{2,3}(?:-[A-Za-z0-9]+)*/);
122
+ if (matched) return matched[0];
123
+ } else if (process.platform === "win32") {
124
+ const ui = run("powershell -NoProfile -Command \"(Get-UICulture).Name\"");
125
+ if (ui) return ui;
126
+ }
127
+ const env = process.env.LC_ALL || process.env.LC_MESSAGES || process.env.LANG;
128
+ if (env) return env;
129
+ try {
130
+ return Intl.DateTimeFormat().resolvedOptions().locale;
131
+ } catch {
132
+ return "";
133
+ }
134
+ }
135
+ function detectLang() {
136
+ const cached = process.env.SIUUU_RESOLVED_LANG;
137
+ if (cached === "zh" || cached === "en") return cached;
138
+ const result = systemLocale().toLowerCase().split(/[-_]/)[0] === "zh" ? "zh" : "en";
139
+ process.env.SIUUU_RESOLVED_LANG = result;
140
+ return result;
141
+ }
142
+ const lang = detectLang();
143
+ i18next.init({
144
+ lng: lang,
145
+ fallbackLng: "en",
146
+ initAsync: false,
147
+ resources: {
148
+ en: { translation: {
149
+ help: `{{name}} v{{version}} — compress PNG / JPEG / WebP images
150
+
151
+ Usage:
152
+ npx {{name}} <files or directories...> [options]
153
+
154
+ Options:
155
+ -o, --out <filename> output filename for the input right before it (repeatable)
156
+ -d, --out-dir <dir> output directory (default: {{dir}}/ in the current directory)
157
+ -f, --format <format> output format: png | jpeg | webp; repeatable to emit several at once (default: same as input)
158
+ -r, --recursive recurse into subdirectories for directory inputs
159
+ -h, --help show help
160
+ -v, --version show version
161
+
162
+ Details:
163
+ Directory inputs are scanned at the top level for PNG / JPEG / WebP; pass -r to recurse. Multiple files run in parallel.
164
+ Inputs without -o keep their original name in the output directory; existing files are never overwritten (falls back to "name (n)").
165
+ With -f every output is converted to that format; -o only sets the filename, not the format.
166
+ Compression: PNG high (quantize ≤200 colors + oxipng), JPEG medium (mozjpeg 75), WebP high (libwebp 90).
167
+
168
+ Examples:
169
+ npx {{name}} a.png b.jpg # compress into {{dir}}/a.png, {{dir}}/b.jpg
170
+ npx {{name}} photos/ -f webp # recursively compress photos/ and convert all to webp
171
+ npx {{name}} a.png -f png -f webp # emit both a.png and a.webp at once
172
+ npx {{name}} a.png -o x.png b.png -o y.png # set output filenames individually
173
+ npx {{name}} imgs/ -d dist/ # write outputs to dist/`,
174
+ argError: "Argument error: {{message}}",
175
+ unsupportedFormat: "Error: unsupported format \"{{format}}\", choose png / jpeg / webp",
176
+ outMustFollowInput: "Error: -o must follow an input file",
177
+ dirNoOut: "Error: directory input {{input}} cannot take a single output name via -o",
178
+ noImages: "No images found to compress",
179
+ summary: "{{files}} files · {{ok}} ok · {{failed}} failed · saved {{percent}}% ({{before}} → {{after}})",
180
+ unrecognizedFormat: "Unrecognized image format; only PNG / JPEG / WebP are supported",
181
+ workerExit: "Worker exited unexpectedly (code {{code}})",
182
+ parallelFallback: "Parallel compression unavailable; fell back to serial processing for {{count}} image(s)"
183
+ } },
184
+ zh: { translation: {
185
+ help: `{{name}} v{{version}} — 压缩 PNG / JPEG / WebP 图片
186
+
187
+ 用法:
188
+ npx {{name}} <文件或目录...> [选项]
189
+
190
+ 选项:
191
+ -o, --out <文件名> 为紧邻的前一个输入单独指定输出文件名(可多次)
192
+ -d, --out-dir <目录> 全局输出目录(默认:当前目录下的 {{dir}}/)
193
+ -f, --format <格式> 输出格式 png | jpeg | webp,可重复指定一次导出多种(默认与输入一致)
194
+ -r, --recursive 目录输入时递归子目录
195
+ -h, --help 显示帮助
196
+ -v, --version 显示版本
197
+
198
+ 说明:
199
+ 传入目录时只压缩顶层的 PNG / JPEG / WebP,加 -r 才递归子目录;多文件时自动多线程并行。
200
+ 未用 -o 单独命名的,按原文件名输出到输出目录;同名不覆盖,自动改用「名称 (n)」。
201
+ 指定 -f 后所有输出都转为该格式;-o 只决定文件名,不影响格式。
202
+ 压缩参数:PNG 高画质(量化 ≤200 色 + oxipng)、JPEG 中画质(mozjpeg 75)、WebP 高画质(libwebp 90)。
203
+
204
+ 示例:
205
+ npx {{name}} a.png b.jpg # 压缩到 {{dir}}/a.png、{{dir}}/b.jpg
206
+ npx {{name}} photos/ -f webp # 递归压缩 photos/ 并全部转 webp
207
+ npx {{name}} a.png -f png -f webp # 同时导出 a.png 和 a.webp
208
+ npx {{name}} a.png -o x.png b.png -o y.png # 分别指定输出文件名
209
+ npx {{name}} imgs/ -d dist/ # 输出到 dist/`,
210
+ argError: "参数错误:{{message}}",
211
+ unsupportedFormat: "错误:不支持的格式「{{format}}」,可选 png / jpeg / webp",
212
+ outMustFollowInput: "错误:-o 必须跟在某个输入文件之后",
213
+ dirNoOut: "错误:目录输入 {{input}} 不能用 -o 指定单个输出文件名",
214
+ noImages: "没有找到可压缩的图片",
215
+ summary: "{{files}} 个文件 · {{ok}} 成功 · {{failed}} 失败 · 节省 {{percent}}%({{before}} → {{after}})",
216
+ unrecognizedFormat: "无法识别的图片格式,仅支持 PNG / JPEG / WebP",
217
+ workerExit: "worker 异常退出(code {{code}})",
218
+ parallelFallback: "并行压缩不可用,已回退主线程串行处理 {{count}} 张"
219
+ } }
220
+ },
221
+ interpolation: { escapeValue: false }
222
+ });
223
+ function t(key, params) {
224
+ return i18next.t(key, params);
225
+ }
226
+ //#endregion
227
+ //#region src/index.ts
228
+ /** 通过魔数识别图片格式,无法识别时返回 undefined。 */
229
+ function detectFormat(data) {
230
+ if (data.length >= 8 && data[0] === 137 && data[1] === 80 && data[2] === 78 && data[3] === 71) return "png";
231
+ if (data.length >= 3 && data[0] === 255 && data[1] === 216 && data[2] === 255) return "jpeg";
232
+ if (data.length >= 12 && data[0] === 82 && data[1] === 73 && data[2] === 70 && data[3] === 70 && data[8] === 87 && data[9] === 69 && data[10] === 66 && data[11] === 80) return "webp";
233
+ }
234
+ /** 压缩单张图片。 */
235
+ async function compress(input, options = {}) {
236
+ const inputFormat = detectFormat(input);
237
+ if (!inputFormat) throw new Error(t("unrecognizedFormat"));
238
+ const format = options.format ?? inputFormat;
239
+ return {
240
+ data: await encode(await decode(input, inputFormat), format),
241
+ format
242
+ };
243
+ }
244
+ //#endregion
245
+ export { PRESETS as i, detectFormat as n, t as r, compress as t };
package/dist/worker.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { t as compress } from "./src-CqREwc6F.mjs";
1
+ import { t as compress } from "./src-DYId9YLF.mjs";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { parentPort } from "node:worker_threads";
4
4
  //#region src/worker.ts
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "siuuu",
3
3
  "type": "module",
4
- "version": "1.0.0",
4
+ "version": "1.1.0",
5
5
  "description": "A CLI that compresses images (PNG / JPEG / WebP) with WebAssembly codecs.",
6
6
  "author": "Author Name <author.name@mail.com>",
7
7
  "license": "MIT",
@@ -29,7 +29,7 @@
29
29
  "build": "tsdown",
30
30
  "dev": "tsdown --watch",
31
31
  "test": "vitest",
32
- "play": "tsx scripts/play.ts",
32
+ "play": "tsdown && tsx scripts/play.ts",
33
33
  "typecheck": "tsc --noEmit",
34
34
  "release": "bumpp",
35
35
  "prepublishOnly": "pnpm run build"
@@ -48,6 +48,8 @@
48
48
  "@jsquash/oxipng": "^2.3.0",
49
49
  "@jsquash/png": "^3.1.1",
50
50
  "@jsquash/webp": "^1.5.0",
51
- "imagequant": "^0.1.2"
51
+ "i18next": "^26.3.0",
52
+ "imagequant": "^0.1.2",
53
+ "picocolors": "^1.1.1"
52
54
  }
53
55
  }
@@ -1,119 +0,0 @@
1
- import { createRequire } from "node:module";
2
- import { readFile } from "node:fs/promises";
3
- import jpegDecode, { init } from "@jsquash/jpeg/decode.js";
4
- import jpegEncode, { init as init$1 } from "@jsquash/jpeg/encode.js";
5
- import pngDecode, { init as init$2 } from "@jsquash/png/decode.js";
6
- import webpDecode, { init as init$3 } from "@jsquash/webp/decode.js";
7
- import webpEncode, { init as init$4 } from "@jsquash/webp/encode.js";
8
- import oxipngOptimise, { init as init$5 } from "@jsquash/oxipng/optimise.js";
9
- import * as imagequant from "imagequant/imagequant_bg.js";
10
- //#region src/codecs.ts
11
- const PRESETS = {
12
- png: {
13
- maxColors: 200,
14
- oxipngLevel: 2
15
- },
16
- jpeg: { quality: 75 },
17
- webp: {
18
- quality: 90,
19
- method: 6,
20
- lossless: 0
21
- }
22
- };
23
- const require = createRequire(import.meta.url);
24
- async function wasmBytes(spec) {
25
- return new Uint8Array(await readFile(require.resolve(spec)));
26
- }
27
- async function wasmModule(spec) {
28
- return WebAssembly.compile(await wasmBytes(spec));
29
- }
30
- let jpegDecodeReady;
31
- let jpegEncodeReady;
32
- let pngDecodeReady;
33
- let webpDecodeReady;
34
- let webpEncodeReady;
35
- let oxipngReady;
36
- let imagequantReady;
37
- async function ensureImagequant() {
38
- imagequantReady ??= (async () => {
39
- const { instance } = await WebAssembly.instantiate(await wasmBytes("imagequant/imagequant_bg.wasm"), { "./imagequant_bg.js": imagequant });
40
- imagequant.__wbg_set_wasm(instance.exports);
41
- })();
42
- return imagequantReady;
43
- }
44
- function asBuffer(data) {
45
- return data;
46
- }
47
- function asImageData(image) {
48
- return image;
49
- }
50
- function toUint8(data) {
51
- return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
52
- }
53
- async function decode(data, format) {
54
- switch (format) {
55
- case "png":
56
- pngDecodeReady ??= init$2(await wasmBytes("@jsquash/png/codec/pkg/squoosh_png_bg.wasm"));
57
- await pngDecodeReady;
58
- return pngDecode(asBuffer(data));
59
- case "jpeg":
60
- jpegDecodeReady ??= init(await wasmModule("@jsquash/jpeg/codec/dec/mozjpeg_dec.wasm"));
61
- await jpegDecodeReady;
62
- return jpegDecode(asBuffer(data));
63
- case "webp":
64
- webpDecodeReady ??= init$3(await wasmModule("@jsquash/webp/codec/dec/webp_dec.wasm"));
65
- await webpDecodeReady;
66
- return webpDecode(asBuffer(data));
67
- }
68
- }
69
- async function encodePng(image) {
70
- await ensureImagequant();
71
- const source = imagequant.Imagequant.new_image(toUint8(image.data), image.width, image.height, 0);
72
- const quantizer = new imagequant.Imagequant();
73
- quantizer.set_max_colors(PRESETS.png.maxColors);
74
- const quantized = quantizer.process(source);
75
- oxipngReady ??= init$5(await wasmBytes("@jsquash/oxipng/codec/pkg/squoosh_oxipng_bg.wasm"));
76
- await oxipngReady;
77
- const optimised = await oxipngOptimise(asBuffer(quantized), {
78
- level: PRESETS.png.oxipngLevel,
79
- interlace: false
80
- });
81
- return new Uint8Array(optimised);
82
- }
83
- async function encodeJpeg(image) {
84
- jpegEncodeReady ??= init$1(await wasmModule("@jsquash/jpeg/codec/enc/mozjpeg_enc.wasm"));
85
- await jpegEncodeReady;
86
- return new Uint8Array(await jpegEncode(asImageData(image), { quality: PRESETS.jpeg.quality }));
87
- }
88
- async function encodeWebp(image) {
89
- webpEncodeReady ??= init$4(await wasmModule("@jsquash/webp/codec/enc/webp_enc_simd.wasm"));
90
- await webpEncodeReady;
91
- return new Uint8Array(await webpEncode(asImageData(image), { ...PRESETS.webp }));
92
- }
93
- async function encode(image, format) {
94
- switch (format) {
95
- case "png": return encodePng(image);
96
- case "jpeg": return encodeJpeg(image);
97
- case "webp": return encodeWebp(image);
98
- }
99
- }
100
- //#endregion
101
- //#region src/index.ts
102
- /** 通过魔数识别图片格式,无法识别时返回 undefined。 */
103
- function detectFormat(data) {
104
- if (data.length >= 8 && data[0] === 137 && data[1] === 80 && data[2] === 78 && data[3] === 71) return "png";
105
- if (data.length >= 3 && data[0] === 255 && data[1] === 216 && data[2] === 255) return "jpeg";
106
- if (data.length >= 12 && data[0] === 82 && data[1] === 73 && data[2] === 70 && data[3] === 70 && data[8] === 87 && data[9] === 69 && data[10] === 66 && data[11] === 80) return "webp";
107
- }
108
- /** 压缩单张图片。 */
109
- async function compress(input, options = {}) {
110
- const inputFormat = detectFormat(input);
111
- if (!inputFormat) throw new Error("无法识别的图片格式,第一版仅支持 PNG / JPEG / WebP");
112
- const format = options.format ?? inputFormat;
113
- return {
114
- data: await encode(await decode(input, inputFormat), format),
115
- format
116
- };
117
- }
118
- //#endregion
119
- export { detectFormat as n, PRESETS as r, compress as t };