siuuu 0.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 +24 -13
- package/dist/cli.mjs +95 -57
- package/dist/index.mjs +1 -1
- package/dist/src-DYId9YLF.mjs +245 -0
- package/dist/worker.mjs +1 -1
- package/package.json +7 -5
- package/dist/src-CqREwc6F.mjs +0 -119
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# Siuuu
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/siuuu)
|
|
4
|
+
|
|
3
5
|
Compress images (PNG / JPEG / WebP) with WebAssembly codecs (imagequant + oxipng, mozjpeg, libwebp).
|
|
4
6
|
|
|
5
7
|
## Usage
|
|
@@ -11,28 +13,37 @@ npx siuuu <files or directories...> [options]
|
|
|
11
13
|
Options:
|
|
12
14
|
|
|
13
15
|
- `-o, --out <filename>` — output filename for the input right before it (repeatable)
|
|
14
|
-
- `-d, --out-dir <dir>` — output directory (default: `siuuu
|
|
15
|
-
- `-f, --format <format>` — output format: `png` / `jpeg` / `webp
|
|
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
|
|
16
19
|
- `-h, --help` — show help
|
|
17
20
|
- `-v, --version` — show version
|
|
18
21
|
|
|
19
|
-
|
|
22
|
+
Same-name outputs are never overwritten — they fall back to `name (n)`.
|
|
20
23
|
|
|
21
24
|
## Examples
|
|
22
25
|
|
|
23
26
|
```bash
|
|
24
|
-
#
|
|
25
|
-
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
|
|
29
|
+
|
|
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
|
|
32
|
+
|
|
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
|
|
26
35
|
|
|
27
|
-
#
|
|
28
|
-
npx siuuu
|
|
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
|
|
29
38
|
|
|
30
|
-
#
|
|
31
|
-
npx siuuu
|
|
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
|
|
32
44
|
|
|
33
|
-
|
|
34
|
-
npx siuuu a.png -o x.png b.png -o y.png
|
|
45
|
+
Install the agent [skill](https://skills.sh) for the `siuuu` CLI:
|
|
35
46
|
|
|
36
|
-
|
|
37
|
-
npx
|
|
47
|
+
```bash
|
|
48
|
+
npx skills add WBBB0730/siuuu
|
|
38
49
|
```
|
package/dist/cli.mjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { t as compress } from "./src-
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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(
|
|
226
|
-
process.stdout.write(
|
|
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(
|
|
240
|
+
process.stdout.write(`${help()}\n`);
|
|
237
241
|
if (positionals.length === 0 && !values.help) process.exitCode = 1;
|
|
238
242
|
return;
|
|
239
243
|
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
if (!
|
|
244
|
-
console.error(
|
|
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("
|
|
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(
|
|
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)))
|
|
269
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
292
|
-
|
|
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
|
-
|
|
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 {
|
|
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
package/package.json
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "siuuu",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "
|
|
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",
|
|
8
8
|
"engines": {
|
|
9
9
|
"node": ">=20"
|
|
10
10
|
},
|
|
11
|
-
"homepage": "https://github.com/WBBB0730/
|
|
11
|
+
"homepage": "https://github.com/WBBB0730/siuuu#readme",
|
|
12
12
|
"repository": {
|
|
13
13
|
"type": "git",
|
|
14
|
-
"url": "https://github.com/WBBB0730/
|
|
14
|
+
"url": "https://github.com/WBBB0730/siuuu.git"
|
|
15
15
|
},
|
|
16
16
|
"exports": {
|
|
17
17
|
".": "./dist/index.mjs",
|
|
@@ -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
|
-
"
|
|
51
|
+
"i18next": "^26.3.0",
|
|
52
|
+
"imagequant": "^0.1.2",
|
|
53
|
+
"picocolors": "^1.1.1"
|
|
52
54
|
}
|
|
53
55
|
}
|
package/dist/src-CqREwc6F.mjs
DELETED
|
@@ -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 };
|