picture-tiny-cli 0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 jouryjc
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,58 @@
1
+ # picture-tiny-cli (`ptiny`)
2
+
3
+ 在 agent 中可靠调用的图片压缩 CLI:压到目标文件体积和/或像素尺寸,最小程度影响画质,stdout 始终输出纯 JSON。
4
+
5
+ > **运行时要求:bun。** `ptiny` 以 bun 执行 TypeScript 源码(依赖原生 `sharp`)。请先安装 [bun](https://bun.sh)。
6
+
7
+ ## 安装 / 使用
8
+
9
+ ```bash
10
+ # 免安装直接用(推荐)
11
+ bunx picture-tiny-cli photo.jpg --max-size 500kb
12
+
13
+ # 或全局安装后用 ptiny(PATH 上需有 bun)
14
+ bun add -g picture-tiny-cli # 或 npm i -g picture-tiny-cli
15
+ ptiny photo.jpg --max-size 500kb
16
+ ```
17
+
18
+ ## 用法
19
+
20
+ ```bash
21
+ # 压到 500KB 以内,保留原格式
22
+ ptiny photo.jpg --max-size 500kb
23
+
24
+ # 限制最长边 1600px 并转 webp,输出到目录
25
+ ptiny ./imgs/*.png --max-side 1600 --format webp --out-dir ./out
26
+
27
+ # 同时限定体积与尺寸
28
+ ptiny photo.jpg --max-size 300kb --max-side 1920
29
+ ```
30
+
31
+ > 从源码运行:`bun install` 后 `bun bin/ptiny …`。
32
+
33
+ ## 选项
34
+
35
+ 见 `bun bin/ptiny --help`。
36
+
37
+ ## 输出(JSON)
38
+
39
+ ```json
40
+ {
41
+ "ok": true,
42
+ "summary": { "count": 1, "ok": 1, "failed": 0, "savedPercent": 64.0 },
43
+ "results": [
44
+ { "input": "photo.jpg", "output": "photo.min.jpg", "ok": true,
45
+ "outputBytes": 180000, "quality": 78, "reachedTarget": true, "warnings": [] }
46
+ ],
47
+ "errors": []
48
+ }
49
+ ```
50
+
51
+ 退出码:全成功 `0`,部分失败 `1`,用法错误 `2`。
52
+
53
+ ## 库用法
54
+
55
+ ```ts
56
+ import { compressImage } from "picture-tiny-cli";
57
+ const out = await compressImage(buffer, { maxSize: 200 * 1024, maxSide: 1600 });
58
+ ```
package/bin/ptiny ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env bun
2
+ import { main } from "../src/cli";
3
+
4
+ main(process.argv.slice(2))
5
+ .then((code) => process.exit(code))
6
+ .catch((err) => {
7
+ process.stdout.write(JSON.stringify({ ok: false, error: (err as Error).message }) + "\n");
8
+ process.exit(1);
9
+ });
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "picture-tiny-cli",
3
+ "version": "0.1.0",
4
+ "description": "Compress images to a target file size and/or pixel dimensions with minimal quality loss — JPEG/PNG/WebP/AVIF, pure-JSON output for agents. Requires the bun runtime.",
5
+ "type": "module",
6
+ "bin": {
7
+ "ptiny": "bin/ptiny"
8
+ },
9
+ "module": "src/index.ts",
10
+ "files": [
11
+ "bin",
12
+ "src",
13
+ "README.md"
14
+ ],
15
+ "keywords": [
16
+ "image",
17
+ "compression",
18
+ "compress",
19
+ "optimize",
20
+ "resize",
21
+ "sharp",
22
+ "webp",
23
+ "avif",
24
+ "jpeg",
25
+ "png",
26
+ "cli",
27
+ "bun",
28
+ "agent"
29
+ ],
30
+ "license": "MIT",
31
+ "author": "jouryjc",
32
+ "scripts": {
33
+ "test": "bun test",
34
+ "ptiny": "bun bin/ptiny"
35
+ },
36
+ "dependencies": {
37
+ "sharp": "^0.34.5"
38
+ },
39
+ "devDependencies": {
40
+ "@types/bun": "^1.3.14"
41
+ }
42
+ }
package/src/args.ts ADDED
@@ -0,0 +1,136 @@
1
+ import { parseArgs } from "node:util";
2
+ import { parseSize } from "./size";
3
+ import { normalizeFormat, type TargetFormat } from "./format";
4
+
5
+ export class UsageError extends Error {}
6
+
7
+ export interface Options {
8
+ inputs: string[];
9
+ maxSize?: number;
10
+ width?: number;
11
+ height?: number;
12
+ maxSide?: number;
13
+ quality?: number;
14
+ format?: TargetFormat;
15
+ output?: string;
16
+ outDir?: string;
17
+ suffix: string;
18
+ inPlace: boolean;
19
+ recursive: boolean;
20
+ background: string;
21
+ minQuality: number;
22
+ concurrency?: number;
23
+ dryRun: boolean;
24
+ help: boolean;
25
+ version: boolean;
26
+ }
27
+
28
+ function parseIntStrict(raw: string, name: string): number {
29
+ if (!/^\d+$/.test(raw.trim())) throw new UsageError(`--${name} must be a positive integer`);
30
+ const n = parseInt(raw, 10);
31
+ if (n < 1) throw new UsageError(`--${name} must be a positive integer`);
32
+ return n;
33
+ }
34
+
35
+ function parseQuality(raw: string, name: string): number {
36
+ const q = parseIntStrict(raw, name);
37
+ if (q > 100) throw new UsageError(`--${name} must be 1-100`);
38
+ return q;
39
+ }
40
+
41
+ export function parseCliArgs(argv: string[]): Options {
42
+ let parsed;
43
+ try {
44
+ parsed = parseArgs({
45
+ args: argv,
46
+ allowPositionals: true,
47
+ options: {
48
+ "max-size": { type: "string" },
49
+ width: { type: "string" },
50
+ height: { type: "string" },
51
+ "max-side": { type: "string" },
52
+ quality: { type: "string" },
53
+ format: { type: "string" },
54
+ output: { type: "string" },
55
+ "out-dir": { type: "string" },
56
+ suffix: { type: "string" },
57
+ "in-place": { type: "boolean", default: false },
58
+ recursive: { type: "boolean", default: false },
59
+ background: { type: "string" },
60
+ "min-quality": { type: "string" },
61
+ concurrency: { type: "string" },
62
+ "dry-run": { type: "boolean", default: false },
63
+ help: { type: "boolean", default: false },
64
+ version: { type: "boolean", default: false },
65
+ },
66
+ });
67
+ } catch (err) {
68
+ throw new UsageError((err as Error).message);
69
+ }
70
+
71
+ const v = parsed.values;
72
+ const opts: Options = {
73
+ inputs: parsed.positionals,
74
+ suffix: v.suffix ?? ".min",
75
+ inPlace: v["in-place"] ?? false,
76
+ recursive: v.recursive ?? false,
77
+ background: v.background ?? "#ffffff",
78
+ minQuality: v["min-quality"] != null ? parseQuality(v["min-quality"], "min-quality") : 1,
79
+ dryRun: v["dry-run"] ?? false,
80
+ help: v.help ?? false,
81
+ version: v.version ?? false,
82
+ };
83
+
84
+ if (v["max-size"] != null) {
85
+ try {
86
+ opts.maxSize = parseSize(v["max-size"]);
87
+ } catch (err) {
88
+ throw new UsageError((err as Error).message);
89
+ }
90
+ }
91
+ if (v.width != null) opts.width = parseIntStrict(v.width, "width");
92
+ if (v.height != null) opts.height = parseIntStrict(v.height, "height");
93
+ if (v["max-side"] != null) opts.maxSide = parseIntStrict(v["max-side"], "max-side");
94
+ if (v.quality != null) opts.quality = parseQuality(v.quality, "quality");
95
+ if (v.format != null) {
96
+ try {
97
+ opts.format = normalizeFormat(v.format);
98
+ } catch (err) {
99
+ throw new UsageError((err as Error).message);
100
+ }
101
+ }
102
+ if (v.output != null) opts.output = v.output;
103
+ if (v["out-dir"] != null) opts.outDir = v["out-dir"];
104
+ if (v.concurrency != null) opts.concurrency = parseIntStrict(v.concurrency, "concurrency");
105
+
106
+ if (opts.help || opts.version) return opts;
107
+
108
+ validate(opts);
109
+ return opts;
110
+ }
111
+
112
+ function validate(opts: Options): void {
113
+ if (opts.inputs.length === 0) throw new UsageError("no input files given");
114
+ const hasConstraint =
115
+ opts.maxSize != null ||
116
+ opts.width != null ||
117
+ opts.height != null ||
118
+ opts.maxSide != null ||
119
+ opts.quality != null;
120
+ if (!hasConstraint) {
121
+ throw new UsageError(
122
+ "at least one of --max-size, --width, --height, --max-side, --quality is required",
123
+ );
124
+ }
125
+ if (opts.output != null && opts.inputs.length > 1) {
126
+ throw new UsageError("--output cannot be used with multiple inputs; use --out-dir");
127
+ }
128
+ if (opts.inPlace && (opts.output != null || opts.outDir != null)) {
129
+ throw new UsageError("--in-place cannot be combined with --output or --out-dir");
130
+ }
131
+ if (opts.inPlace && opts.format != null) {
132
+ throw new UsageError(
133
+ "--in-place cannot be combined with --format (it would change the extension and orphan the original); use --out-dir or --output",
134
+ );
135
+ }
136
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,105 @@
1
+ import { parseCliArgs, UsageError } from "./args";
2
+ import { expandInputs } from "./inputs";
3
+ import { processAll, type FileResult } from "./run";
4
+
5
+ const VERSION = "0.1.0";
6
+
7
+ const HELP = `ptiny - compress images to a target file size and/or pixel size
8
+
9
+ Usage: ptiny [options] <input...>
10
+
11
+ Targets (at least one required):
12
+ --max-size <500kb|1.5mb|N> target max file size
13
+ --width <px> target width
14
+ --height <px> target height
15
+ --max-side <px> cap the longest side
16
+ --quality <1-100> fixed quality (skip size search)
17
+ Format:
18
+ --format <jpeg|png|webp|avif> convert format (default: keep original)
19
+ Output:
20
+ --output <file> output path (single input only)
21
+ --out-dir <dir> output directory
22
+ --suffix <.min> sibling suffix (default .min)
23
+ --in-place overwrite originals
24
+ --recursive recurse into directories
25
+ Behavior:
26
+ --background <white> flatten color for alpha->jpeg
27
+ --min-quality <n> quality search floor (default 1)
28
+ --concurrency <n> parallel workers (default cpu count)
29
+ --dry-run compute without writing
30
+ --help, --version`;
31
+
32
+ function sum(ns: number[]): number {
33
+ return ns.reduce((a, b) => a + b, 0);
34
+ }
35
+ function round1(n: number): number {
36
+ return Math.round(n * 10) / 10;
37
+ }
38
+
39
+ function emit(payload: unknown): void {
40
+ process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
41
+ }
42
+
43
+ /** CLI 主入口,返回退出码。stdout 始终为 JSON,日志走 stderr。 */
44
+ export async function main(argv: string[]): Promise<number> {
45
+ let opts;
46
+ try {
47
+ opts = parseCliArgs(argv);
48
+ } catch (err) {
49
+ if (err instanceof UsageError) {
50
+ emit({ ok: false, error: err.message });
51
+ return 2;
52
+ }
53
+ throw err;
54
+ }
55
+
56
+ if (opts.help) {
57
+ emit({ ok: true, help: HELP });
58
+ return 0;
59
+ }
60
+ if (opts.version) {
61
+ emit({ ok: true, version: VERSION });
62
+ return 0;
63
+ }
64
+
65
+ let files: string[];
66
+ try {
67
+ files = await expandInputs(opts.inputs, opts.recursive);
68
+ } catch (err) {
69
+ emit({ ok: false, error: (err as Error).message });
70
+ return 2;
71
+ }
72
+ if (files.length === 0) {
73
+ emit({ ok: false, error: "no image files matched the given inputs" });
74
+ return 2;
75
+ }
76
+
77
+ const results = await processAll(files, opts);
78
+ const okResults = results.filter((r) => r.ok);
79
+ const errorResults = results.filter((r) => !r.ok);
80
+
81
+ const originalBytes = sum(okResults.map((r) => r.originalBytes ?? 0));
82
+ const outputBytes = sum(okResults.map((r) => r.outputBytes ?? 0));
83
+ const savedBytes = originalBytes - outputBytes;
84
+
85
+ const payload = {
86
+ ok: errorResults.length === 0,
87
+ summary: {
88
+ count: results.length,
89
+ ok: okResults.length,
90
+ failed: errorResults.length,
91
+ originalBytes,
92
+ outputBytes,
93
+ savedBytes,
94
+ savedPercent: originalBytes > 0 ? round1((savedBytes / originalBytes) * 100) : 0,
95
+ },
96
+ results: okResults,
97
+ errors: errorResults.map((r: FileResult) => ({
98
+ input: r.input,
99
+ ok: false,
100
+ error: r.error,
101
+ })),
102
+ };
103
+ emit(payload);
104
+ return errorResults.length === 0 ? 0 : 1;
105
+ }
@@ -0,0 +1,189 @@
1
+ import sharp from "sharp";
2
+ import type { Sharp, ResizeOptions } from "sharp";
3
+ import {
4
+ applyEncoder,
5
+ normalizeFormat,
6
+ DEFAULT_QUALITY,
7
+ LOSSY_FORMATS,
8
+ type TargetFormat,
9
+ } from "./format";
10
+
11
+ export interface CompressOptions {
12
+ maxSize?: number; // 目标最大字节数
13
+ width?: number;
14
+ height?: number;
15
+ maxSide?: number;
16
+ quality?: number; // 固定质量 1-100
17
+ format?: TargetFormat; // 显式目标格式
18
+ background?: string; // alpha→jpeg 压平底色
19
+ minQuality?: number; // 质量搜索下界,默认 1
20
+ }
21
+
22
+ export interface CompressResult {
23
+ buffer: Buffer;
24
+ format: TargetFormat;
25
+ originalFormat: string;
26
+ width: number;
27
+ height: number;
28
+ originalWidth: number;
29
+ originalHeight: number;
30
+ quality: number | null;
31
+ targetBytes: number | null;
32
+ reachedTarget: boolean;
33
+ iterations: number;
34
+ warnings: string[];
35
+ }
36
+
37
+ /** 多页(动图)保护:pages>1 抛错。 */
38
+ export function assertStatic(pages: number | undefined): void {
39
+ if ((pages ?? 1) > 1) throw new Error("animated images are not supported in v1");
40
+ }
41
+
42
+ function clampQuality(q: number): number {
43
+ return Math.max(1, Math.min(100, Math.round(q)));
44
+ }
45
+
46
+ /** 计算 resize 选项;不放大时返回 null 并记录 warning。 */
47
+ function planResize(
48
+ opts: CompressOptions,
49
+ ow: number,
50
+ oh: number,
51
+ warnings: string[],
52
+ ): ResizeOptions | null {
53
+ let width = opts.width;
54
+ let height = opts.height;
55
+ if (opts.maxSide != null) {
56
+ width = Math.min(width ?? opts.maxSide, opts.maxSide);
57
+ height = Math.min(height ?? opts.maxSide, opts.maxSide);
58
+ }
59
+ if (width == null && height == null) return null;
60
+ const fitsW = width == null || width >= ow;
61
+ const fitsH = height == null || height >= oh;
62
+ if (fitsW && fitsH) {
63
+ warnings.push("requested size is larger than or equal to original; not upscaling");
64
+ return null;
65
+ }
66
+ return { width, height, fit: "inside", withoutEnlargement: true };
67
+ }
68
+
69
+ /**
70
+ * 二分搜索质量:取 ≤target 的最高质量;都不满足时返回最小可得(best effort)。
71
+ */
72
+ async function searchQuality(
73
+ encode: (q: number) => Promise<Buffer>,
74
+ targetBytes: number,
75
+ minQuality: number,
76
+ ): Promise<{ buffer: Buffer; quality: number; iterations: number; reached: boolean }> {
77
+ let lo = Math.max(1, Math.min(100, Math.round(minQuality)));
78
+ let hi = 100;
79
+ let iterations = 0;
80
+ let bestFit: { buffer: Buffer; quality: number } | null = null;
81
+ let smallest: { buffer: Buffer; quality: number; size: number } | null = null;
82
+
83
+ while (lo <= hi) {
84
+ const mid = Math.floor((lo + hi) / 2);
85
+ iterations++;
86
+ const buf = await encode(mid);
87
+ if (smallest == null || buf.length < smallest.size) {
88
+ smallest = { buffer: buf, quality: mid, size: buf.length };
89
+ }
90
+ if (buf.length <= targetBytes) {
91
+ if (bestFit == null || mid > bestFit.quality) bestFit = { buffer: buf, quality: mid };
92
+ lo = mid + 1; // 体积达标,尝试更高质量
93
+ } else {
94
+ hi = mid - 1; // 超标,降质量
95
+ }
96
+ }
97
+
98
+ if (bestFit) {
99
+ return { buffer: bestFit.buffer, quality: bestFit.quality, iterations, reached: true };
100
+ }
101
+ return { buffer: smallest!.buffer, quality: smallest!.quality, iterations, reached: false };
102
+ }
103
+
104
+ /** 压缩单张图片:先 resize,再按约束选择固定质量 / 二分搜索 / 尺寸-only 编码。 */
105
+ export async function compressImage(
106
+ input: Buffer,
107
+ opts: CompressOptions,
108
+ ): Promise<CompressResult> {
109
+ const meta = await sharp(input, { failOn: "none" }).metadata();
110
+ assertStatic(meta.pages);
111
+
112
+ const originalFormat = meta.format ?? "unknown";
113
+ const originalWidth = meta.width ?? 0;
114
+ const originalHeight = meta.height ?? 0;
115
+ const hasAlpha = meta.hasAlpha ?? false;
116
+ const targetFormat: TargetFormat = opts.format ?? normalizeFormat(originalFormat);
117
+ const warnings: string[] = [];
118
+ const resize = planResize(opts, originalWidth, originalHeight, warnings);
119
+
120
+ function buildPipeline(): Sharp {
121
+ let p = sharp(input, { failOn: "none" });
122
+ if (resize) p = p.resize(resize);
123
+ if (targetFormat === "jpeg" && hasAlpha) {
124
+ p = p.flatten({ background: opts.background ?? "#ffffff" });
125
+ }
126
+ return p;
127
+ }
128
+
129
+ const encode = (quality: number | null): Promise<Buffer> =>
130
+ applyEncoder(buildPipeline(), targetFormat, quality).toBuffer();
131
+
132
+ const targetBytes = opts.maxSize ?? null;
133
+ let resultBuffer: Buffer;
134
+ let chosenQuality: number | null;
135
+ let iterations = 0;
136
+ let reachedTarget = true;
137
+
138
+ if (opts.quality != null) {
139
+ chosenQuality = clampQuality(opts.quality);
140
+ resultBuffer = await encode(chosenQuality);
141
+ if (targetBytes != null) {
142
+ reachedTarget = resultBuffer.length <= targetBytes;
143
+ if (!reachedTarget) {
144
+ warnings.push(
145
+ `fixed --quality ${chosenQuality} produced ${resultBuffer.length} bytes, ` +
146
+ `exceeding target ${targetBytes}; omit --quality to let size search pick a smaller quality`,
147
+ );
148
+ }
149
+ }
150
+ } else if (targetBytes != null) {
151
+ const search = await searchQuality(
152
+ (q) => encode(q),
153
+ targetBytes,
154
+ opts.minQuality ?? 1,
155
+ );
156
+ resultBuffer = search.buffer;
157
+ chosenQuality = search.quality;
158
+ iterations = search.iterations;
159
+ reachedTarget = search.reached;
160
+ if (!reachedTarget) {
161
+ warnings.push(
162
+ `could not reach target size; emitted best effort at quality ${chosenQuality}`,
163
+ );
164
+ if (targetFormat === "png") {
165
+ warnings.push("consider --format webp for better compression of this image");
166
+ }
167
+ }
168
+ } else {
169
+ // 仅尺寸约束:有损用默认质量,png 走无损(quality=null)
170
+ chosenQuality = LOSSY_FORMATS.has(targetFormat) ? DEFAULT_QUALITY : null;
171
+ resultBuffer = await encode(chosenQuality);
172
+ }
173
+
174
+ const outMeta = await sharp(resultBuffer, { failOn: "none" }).metadata();
175
+ return {
176
+ buffer: resultBuffer,
177
+ format: targetFormat,
178
+ originalFormat,
179
+ width: outMeta.width ?? originalWidth,
180
+ height: outMeta.height ?? originalHeight,
181
+ originalWidth,
182
+ originalHeight,
183
+ quality: chosenQuality,
184
+ targetBytes,
185
+ reachedTarget,
186
+ iterations,
187
+ warnings,
188
+ };
189
+ }
package/src/format.ts ADDED
@@ -0,0 +1,40 @@
1
+ import type { Sharp } from "sharp";
2
+
3
+ export type TargetFormat = "jpeg" | "png" | "webp" | "avif";
4
+
5
+ export const DEFAULT_QUALITY = 82;
6
+ export const LOSSY_FORMATS = new Set<TargetFormat>(["jpeg", "webp", "avif"]);
7
+
8
+ /** 把格式字符串归一化为受支持的目标格式,未知格式抛错。 */
9
+ export function normalizeFormat(fmt: string): TargetFormat {
10
+ const f = fmt.toLowerCase();
11
+ if (f === "jpg" || f === "jpeg") return "jpeg";
12
+ if (f === "png") return "png";
13
+ if (f === "webp") return "webp";
14
+ if (f === "avif") return "avif";
15
+ throw new Error(`unsupported format: ${JSON.stringify(fmt)}`);
16
+ }
17
+
18
+ /**
19
+ * 给 sharp pipeline 应用目标格式编码器。
20
+ * - 有损格式:quality 为 null 时用 DEFAULT_QUALITY。
21
+ * - png:quality 为 null 走无损;为数字走调色板量化。
22
+ */
23
+ export function applyEncoder(pipeline: Sharp, format: TargetFormat, quality: number | null): Sharp {
24
+ switch (format) {
25
+ case "jpeg":
26
+ return pipeline.jpeg({ quality: quality ?? DEFAULT_QUALITY, mozjpeg: true });
27
+ case "webp":
28
+ return pipeline.webp({ quality: quality ?? DEFAULT_QUALITY });
29
+ case "avif":
30
+ return pipeline.avif({ quality: quality ?? DEFAULT_QUALITY });
31
+ case "png":
32
+ return quality == null
33
+ ? pipeline.png({ compressionLevel: 9 })
34
+ : pipeline.png({ quality, palette: true, compressionLevel: 9 });
35
+ default: {
36
+ const never: never = format;
37
+ throw new Error(`unsupported format: ${never}`);
38
+ }
39
+ }
40
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export { compressImage, assertStatic } from "./compress";
2
+ export type { CompressOptions, CompressResult } from "./compress";
3
+ export { parseSize, formatBytes } from "./size";
4
+ export { parseCliArgs, UsageError } from "./args";
5
+ export type { Options } from "./args";
6
+ export { main } from "./cli";
package/src/inputs.ts ADDED
@@ -0,0 +1,53 @@
1
+ import { Glob } from "bun";
2
+ import { stat, readdir } from "node:fs/promises";
3
+ import { join, extname, resolve } from "node:path";
4
+
5
+ const IMAGE_EXTS = new Set([".jpg", ".jpeg", ".png", ".webp", ".avif", ".gif", ".tiff", ".tif"]);
6
+
7
+ function isGlob(s: string): boolean {
8
+ return /[*?[\]{}]/.test(s);
9
+ }
10
+
11
+ async function listDir(dir: string, recursive: boolean): Promise<string[]> {
12
+ const entries = await readdir(dir, { withFileTypes: true });
13
+ const files: string[] = [];
14
+ for (const e of entries) {
15
+ const full = join(dir, e.name);
16
+ if (e.isDirectory()) {
17
+ if (recursive) files.push(...(await listDir(full, recursive)));
18
+ } else {
19
+ files.push(full);
20
+ }
21
+ }
22
+ return files;
23
+ }
24
+
25
+ /** 把输入项(文件/目录/glob)展开为去重、排序后的图片路径列表。 */
26
+ export async function expandInputs(inputs: string[], recursive: boolean): Promise<string[]> {
27
+ const out = new Set<string>();
28
+ for (const item of inputs) {
29
+ if (isGlob(item)) {
30
+ const glob = new Glob(item);
31
+ const cwd = process.cwd();
32
+ for await (const file of glob.scan({ onlyFiles: true, cwd })) {
33
+ const abs = resolve(cwd, file);
34
+ if (IMAGE_EXTS.has(extname(abs).toLowerCase())) out.add(abs);
35
+ }
36
+ continue;
37
+ }
38
+ let st;
39
+ try {
40
+ st = await stat(item);
41
+ } catch {
42
+ throw new Error(`input not found: ${item}`);
43
+ }
44
+ if (st.isDirectory()) {
45
+ for (const f of await listDir(item, recursive)) {
46
+ if (IMAGE_EXTS.has(extname(f).toLowerCase())) out.add(resolve(f));
47
+ }
48
+ } else {
49
+ if (IMAGE_EXTS.has(extname(item).toLowerCase())) out.add(resolve(item));
50
+ }
51
+ }
52
+ return [...out].sort();
53
+ }
package/src/output.ts ADDED
@@ -0,0 +1,24 @@
1
+ import { dirname, basename, extname, join } from "node:path";
2
+ import type { Options } from "./args";
3
+ import type { TargetFormat } from "./format";
4
+
5
+ const FORMAT_EXT: Record<TargetFormat, string> = {
6
+ jpeg: ".jpg",
7
+ png: ".png",
8
+ webp: ".webp",
9
+ avif: ".avif",
10
+ };
11
+
12
+ function changeExt(p: string, ext: string): string {
13
+ return join(dirname(p), basename(p, extname(p)) + ext);
14
+ }
15
+
16
+ /** 依据选项与目标格式解析单个输入文件的输出路径。 */
17
+ export function resolveOutputPath(input: string, format: TargetFormat, opts: Options): string {
18
+ const ext = FORMAT_EXT[format];
19
+ if (opts.output) return opts.output;
20
+ if (opts.inPlace) return changeExt(input, ext);
21
+ if (opts.outDir) return join(opts.outDir, basename(input, extname(input)) + ext);
22
+ const stem = basename(input, extname(input));
23
+ return join(dirname(input), `${stem}${opts.suffix}${ext}`);
24
+ }
package/src/run.ts ADDED
@@ -0,0 +1,126 @@
1
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
2
+ import { dirname, resolve } from "node:path";
3
+ import { cpus } from "node:os";
4
+ import { compressImage } from "./compress";
5
+ import { resolveOutputPath } from "./output";
6
+ import type { Options } from "./args";
7
+
8
+ export interface FileResult {
9
+ input: string;
10
+ output?: string;
11
+ ok: boolean;
12
+ error?: string;
13
+ originalFormat?: string;
14
+ format?: string;
15
+ originalWidth?: number;
16
+ originalHeight?: number;
17
+ width?: number;
18
+ height?: number;
19
+ originalBytes?: number;
20
+ outputBytes?: number;
21
+ savedBytes?: number;
22
+ savedPercent?: number;
23
+ ratio?: number;
24
+ quality?: number | null;
25
+ targetBytes?: number | null;
26
+ reachedTarget?: boolean;
27
+ iterations?: number;
28
+ warnings?: string[];
29
+ }
30
+
31
+ function round1(n: number): number {
32
+ return Math.round(n * 10) / 10;
33
+ }
34
+ function round4(n: number): number {
35
+ return Math.round(n * 10000) / 10000;
36
+ }
37
+
38
+ /**
39
+ * 读取→压缩→写出单个文件,捕获错误为结果记录(不抛出)。
40
+ * `claimed` 跨文件共享,用于检测一次调用内多个输入写到同一输出路径的冲突。
41
+ */
42
+ export async function processFile(
43
+ input: string,
44
+ opts: Options,
45
+ claimed: Set<string> = new Set(),
46
+ ): Promise<FileResult> {
47
+ try {
48
+ const buf = await readFile(input);
49
+ const result = await compressImage(buf, {
50
+ maxSize: opts.maxSize,
51
+ width: opts.width,
52
+ height: opts.height,
53
+ maxSide: opts.maxSide,
54
+ quality: opts.quality,
55
+ format: opts.format,
56
+ background: opts.background,
57
+ minQuality: opts.minQuality,
58
+ });
59
+ const output = resolveOutputPath(input, result.format, opts);
60
+ const resolvedOut = resolve(output);
61
+ // 绝不静默覆盖原图:输出落回输入自身且未显式 --in-place 时拒绝。
62
+ if (!opts.inPlace && resolvedOut === resolve(input)) {
63
+ return {
64
+ input,
65
+ ok: false,
66
+ error: `refusing to overwrite the original without --in-place: ${input}`,
67
+ };
68
+ }
69
+ // 同一次调用内多个输入写到同一输出路径会相互覆盖——报错而非静默丢失。
70
+ if (claimed.has(resolvedOut)) {
71
+ return {
72
+ input,
73
+ ok: false,
74
+ error: `output path collides with another input: ${output}`,
75
+ };
76
+ }
77
+ claimed.add(resolvedOut);
78
+ if (!opts.dryRun) {
79
+ await mkdir(dirname(output), { recursive: true });
80
+ await writeFile(output, result.buffer);
81
+ }
82
+ const originalBytes = buf.length;
83
+ const outputBytes = result.buffer.length;
84
+ return {
85
+ input,
86
+ output,
87
+ ok: true,
88
+ originalFormat: result.originalFormat,
89
+ format: result.format,
90
+ originalWidth: result.originalWidth,
91
+ originalHeight: result.originalHeight,
92
+ width: result.width,
93
+ height: result.height,
94
+ originalBytes,
95
+ outputBytes,
96
+ savedBytes: originalBytes - outputBytes,
97
+ savedPercent: round1((1 - outputBytes / originalBytes) * 100),
98
+ ratio: round4(outputBytes / originalBytes),
99
+ quality: result.quality,
100
+ targetBytes: result.targetBytes,
101
+ reachedTarget: result.reachedTarget,
102
+ iterations: result.iterations,
103
+ warnings: result.warnings,
104
+ };
105
+ } catch (err) {
106
+ return { input, ok: false, error: (err as Error).message };
107
+ }
108
+ }
109
+
110
+ /** 以受限并发处理一组文件,结果顺序与输入一致。 */
111
+ export async function processAll(files: string[], opts: Options): Promise<FileResult[]> {
112
+ const concurrency = Math.max(1, opts.concurrency ?? cpus().length);
113
+ const results: FileResult[] = new Array(files.length);
114
+ const claimed = new Set<string>();
115
+ let next = 0;
116
+ async function worker(): Promise<void> {
117
+ while (true) {
118
+ const i = next++;
119
+ if (i >= files.length) break;
120
+ results[i] = await processFile(files[i]!, opts, claimed);
121
+ }
122
+ }
123
+ const workers = Array.from({ length: Math.min(concurrency, files.length) }, () => worker());
124
+ await Promise.all(workers);
125
+ return results;
126
+ }
package/src/size.ts ADDED
@@ -0,0 +1,37 @@
1
+ const UNITS: Record<string, number> = {
2
+ "": 1,
3
+ b: 1,
4
+ k: 1024,
5
+ kb: 1024,
6
+ kib: 1024,
7
+ m: 1024 ** 2,
8
+ mb: 1024 ** 2,
9
+ mib: 1024 ** 2,
10
+ g: 1024 ** 3,
11
+ gb: 1024 ** 3,
12
+ gib: 1024 ** 3,
13
+ };
14
+
15
+ /** 解析人类可读体积为字节数。纯数字按字节,支持 b/kb/mb/gb(1KB=1024)。 */
16
+ export function parseSize(input: string): number {
17
+ const m = String(input).trim().toLowerCase().match(/^(\d+(?:\.\d+)?)\s*([a-z]*)$/);
18
+ if (!m) throw new Error(`invalid size: ${JSON.stringify(input)}`);
19
+ const value = parseFloat(m[1]!);
20
+ const unit = m[2]!;
21
+ const mult = UNITS[unit];
22
+ if (mult == null) throw new Error(`invalid size unit: ${JSON.stringify(unit)}`);
23
+ return Math.round(value * mult);
24
+ }
25
+
26
+ /** 字节数格式化为人类可读字符串。 */
27
+ export function formatBytes(bytes: number): string {
28
+ if (bytes < 1024) return `${bytes}B`;
29
+ const units = ["KB", "MB", "GB"];
30
+ let v = bytes / 1024;
31
+ let i = 0;
32
+ while (v >= 1024 && i < units.length - 1) {
33
+ v /= 1024;
34
+ i++;
35
+ }
36
+ return `${v.toFixed(1)}${units[i]}`;
37
+ }