@puntoycoma/bulki 3.0.2 → 3.0.3
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 +47 -20
- package/dist/bin/bulki.js +144 -226
- package/dist/bin/bulki.js.map +1 -1
- package/dist/chunk-YUAKNSBH.js +851 -0
- package/dist/chunk-YUAKNSBH.js.map +1 -0
- package/dist/interactive-MTL4CMVQ.js +566 -0
- package/dist/interactive-MTL4CMVQ.js.map +1 -0
- package/package.json +2 -1
- package/dist/chunk-7TFBGWBB.js +0 -532
- package/dist/chunk-7TFBGWBB.js.map +0 -1
- package/dist/interactive-HBGX4FD6.js +0 -295
- package/dist/interactive-HBGX4FD6.js.map +0 -1
package/README.md
CHANGED
|
@@ -35,44 +35,69 @@ npm install -g @puntoycoma/bulki
|
|
|
35
35
|
bulki
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
-
Guided
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
Guided step-by-step mode. Drag a folder (or several images) into the terminal window when it asks
|
|
39
|
+
for a path — quotes, escaped spaces and trailing spaces are handled for you. You can also start it
|
|
40
|
+
with a path: `bulki ~/Desktop/My\ Photos`.
|
|
41
|
+
|
|
42
|
+
Presets:
|
|
43
|
+
- **Web optimized** — WebP, quality 80, max 1920px wide
|
|
44
|
+
- **Social media** — JPEG, quality 85, max 1080px wide
|
|
45
|
+
- **Just make them smaller** — same format and size, quality 80
|
|
46
|
+
- **Custom** — you choose format, quality and size
|
|
47
|
+
|
|
48
|
+
Navigation: **Esc** goes back one step (nothing you chose is lost), **Ctrl+C** quits. A wrong path or
|
|
49
|
+
number is rejected in place so you can fix it. Before starting you get a summary where you can change
|
|
50
|
+
the images, settings or output folder; afterwards you can open the output folder or run again with
|
|
51
|
+
different settings.
|
|
42
52
|
|
|
43
53
|
### CLI mode
|
|
44
54
|
|
|
45
55
|
```bash
|
|
46
|
-
bulki compress ./photos
|
|
47
|
-
bulki compress ./photos -f webp -q 80
|
|
48
|
-
bulki compress
|
|
49
|
-
bulki compress hero.png -f avif
|
|
56
|
+
bulki compress ./photos # → ./bulki-output
|
|
57
|
+
bulki compress ./photos -o ./web -f webp -q 80 # convert to WebP into ./web
|
|
58
|
+
bulki compress "My Photos" -o "Web Ready" -r 1920
|
|
59
|
+
bulki compress hero.png logo.png -f avif # several files at once (globs work too)
|
|
50
60
|
```
|
|
51
61
|
|
|
52
62
|
---
|
|
53
63
|
|
|
54
64
|
## Compress
|
|
55
65
|
|
|
56
|
-
Optimize
|
|
66
|
+
Optimize files and/or folders. **Originals are never modified** — optimized copies are written to the
|
|
67
|
+
output folder, mirroring the folder structure.
|
|
57
68
|
|
|
58
69
|
```bash
|
|
59
|
-
bulki compress <
|
|
70
|
+
bulki compress <paths...> [options]
|
|
60
71
|
```
|
|
61
72
|
|
|
62
73
|
| Option | Alias | Description | Default |
|
|
63
74
|
|--------|-------|-------------|---------|
|
|
75
|
+
| `--output <dir>` | `-o` | Folder where optimized images are written | `./bulki-output` |
|
|
64
76
|
| `--quality <n>` | `-q` | Output quality (1-100) | `70` |
|
|
65
|
-
| `--format <fmt>` | `-f` | `jpeg`, `png`, `webp`, `gif`, `avif` | original |
|
|
66
|
-
| `--resize <width>` | `-r` | Max width in pixels | original |
|
|
67
|
-
| `--
|
|
68
|
-
| `--
|
|
69
|
-
| `--
|
|
70
|
-
| `--
|
|
71
|
-
| `--dry-run` | | Preview without writing | `false` |
|
|
72
|
-
| `--verbose` | | Per-file details | `false` |
|
|
77
|
+
| `--format <fmt>` | `-f` | `jpeg`, `png`, `webp`, `gif`, `avif` | keep original |
|
|
78
|
+
| `--resize <width>` | `-r` | Max width in pixels (never enlarges) | original |
|
|
79
|
+
| `--concurrency <n>` | | Images processed in parallel (1-32) | CPU count (max 8) |
|
|
80
|
+
| `--exclude <names...>` | | Folder names to skip | none |
|
|
81
|
+
| `--dry-run` | | Show what would be written, without writing | `false` |
|
|
82
|
+
| `--verbose` | | One line per file | `false` |
|
|
73
83
|
| `--json` | | Machine-readable JSON output | `false` |
|
|
74
84
|
| `--max-size <size>` | | Fail if output exceeds limit (e.g., `200kb`, `1mb`) | none |
|
|
75
85
|
|
|
86
|
+
The path you want to optimize is the argument (`bulki compress <path>`); `-o` is only where the
|
|
87
|
+
results go. Paths with spaces must be quoted (`"My Photos"`) or escaped (`My\ Photos`) — or just
|
|
88
|
+
drag the folder into the terminal.
|
|
89
|
+
|
|
90
|
+
> **Upgrading from 3.0?** `-d/--destination <dir>` and `--output-folder <name>` still work
|
|
91
|
+
> (`-d x` → `x/bulki-output`, `-d x -o name` → `x/name`) but are deprecated in favor of `-o <dir>`.
|
|
92
|
+
|
|
93
|
+
Behaviour worth knowing:
|
|
94
|
+
- Previous `bulki-output` folders inside the source are skipped, so running twice doesn't nest outputs.
|
|
95
|
+
- If re-encoding would make an image bigger, the original bytes are copied instead.
|
|
96
|
+
- EXIF orientation is applied (phone photos won't come out sideways); transparent PNG → JPEG gets a white background.
|
|
97
|
+
- `photo.jpg` and `photo.png` converted to WebP become `photo.webp` and `photo-png.webp` (nothing is silently overwritten).
|
|
98
|
+
- HEIC/HEIF photos (iPhone) are converted on macOS.
|
|
99
|
+
- A broken image doesn't stop the batch: it's reported at the end and the exit code is `1`.
|
|
100
|
+
|
|
76
101
|
Output preserves your folder structure:
|
|
77
102
|
|
|
78
103
|
```
|
|
@@ -136,7 +161,9 @@ Monitor a directory. Every time a new image appears, bulki optimizes it automati
|
|
|
136
161
|
bulki watch <directory> [options]
|
|
137
162
|
```
|
|
138
163
|
|
|
139
|
-
Same options as compress: `--quality`, `--format`, `--resize
|
|
164
|
+
Same options as compress: `--output`, `--quality`, `--format`, `--resize`. Files are processed once
|
|
165
|
+
they stop changing (large copies aren't read half-written), and edits to an existing image are
|
|
166
|
+
re-optimized.
|
|
140
167
|
|
|
141
168
|
### Examples
|
|
142
169
|
|
|
@@ -145,7 +172,7 @@ Same options as compress: `--quality`, `--format`, `--resize`, `--destination`.
|
|
|
145
172
|
bulki watch ./uploads -f webp -q 80
|
|
146
173
|
|
|
147
174
|
# Watch with resize, output to a different folder
|
|
148
|
-
bulki watch ./photos -r 1920 -q 85 -
|
|
175
|
+
bulki watch ./photos -r 1920 -q 85 -o ./optimized
|
|
149
176
|
```
|
|
150
177
|
|
|
151
178
|
### Use cases
|
package/dist/bin/bulki.js
CHANGED
|
@@ -1,26 +1,33 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
|
-
|
|
3
|
+
DEFAULTS,
|
|
4
|
+
PlanError,
|
|
5
|
+
UNSUPPORTED,
|
|
4
6
|
buildJsonOutput,
|
|
5
|
-
|
|
7
|
+
defaultConcurrency,
|
|
6
8
|
detectFormat,
|
|
9
|
+
displayPath,
|
|
10
|
+
encodeImage,
|
|
7
11
|
formatFileResult,
|
|
8
12
|
formatSummary,
|
|
9
|
-
getFormatOptions,
|
|
10
13
|
hookCommand,
|
|
11
|
-
|
|
14
|
+
parseInteger,
|
|
15
|
+
parsePathInput,
|
|
12
16
|
parseSize,
|
|
13
|
-
|
|
17
|
+
planJobs,
|
|
18
|
+
resolveOutputDir,
|
|
19
|
+
resolveUserPath,
|
|
20
|
+
runBatch,
|
|
21
|
+
validateEncodeSettings,
|
|
14
22
|
validateOptions,
|
|
15
23
|
watchCommand
|
|
16
|
-
} from "../chunk-
|
|
24
|
+
} from "../chunk-YUAKNSBH.js";
|
|
17
25
|
|
|
18
26
|
// src/bin/bulki.ts
|
|
19
27
|
import { createRequire as createRequire2 } from "module";
|
|
20
28
|
import { Command as Command4 } from "commander";
|
|
21
29
|
|
|
22
30
|
// src/commands/compress.ts
|
|
23
|
-
import path from "path";
|
|
24
31
|
import { Command } from "commander";
|
|
25
32
|
|
|
26
33
|
// src/output/progress.ts
|
|
@@ -41,297 +48,208 @@ function createProgressBar(total) {
|
|
|
41
48
|
}
|
|
42
49
|
|
|
43
50
|
// src/commands/compress.ts
|
|
44
|
-
var compressCommand = new Command("compress").description(
|
|
51
|
+
var compressCommand = new Command("compress").description(
|
|
52
|
+
"Compress images. Originals are never modified; optimized copies go to the output folder."
|
|
53
|
+
).argument("<paths...>", "Images and/or folders to optimize (quote paths with spaces)").option("-o, --output <dir>", "Folder where optimized images are written", DEFAULTS.output).option("-q, --quality <n>", "Output quality (1-100)", parseInteger, DEFAULTS.quality).option("-f, --format <fmt>", "Convert to jpeg, png, webp, gif or avif (default: keep format)").option(
|
|
54
|
+
"-r, --resize <width>",
|
|
55
|
+
"Max width in px (smaller images are never enlarged)",
|
|
56
|
+
parseInteger
|
|
57
|
+
).option("--concurrency <n>", "Images processed in parallel (1-32)", parseInteger).option("--exclude <names...>", "Folder names to skip").option("--dry-run", "Show what would be processed without writing files").option("--verbose", "Show one line per file").option("--json", "Output results as JSON").option("--max-size <size>", "Fail if any output is bigger than this (e.g. 200kb, 1mb)").option("-d, --destination <dir>", "Deprecated: base folder; output goes to <dir>/bulki-output").option("--output-folder <name>", "Deprecated: subfolder name inside --destination").addHelpText(
|
|
58
|
+
"after",
|
|
59
|
+
`
|
|
60
|
+
Examples:
|
|
61
|
+
bulki compress ./photos \u2192 ./bulki-output
|
|
62
|
+
bulki compress ./photos -o ./optimized -f webp
|
|
63
|
+
bulki compress "My Photos" -o "Web Ready" -r 1920
|
|
64
|
+
bulki compress *.png -f avif`
|
|
65
|
+
).action(async (inputPaths, opts, cmd) => {
|
|
66
|
+
const outputFromCli = cmd.getOptionValueSource("output") === "cli";
|
|
45
67
|
let options;
|
|
46
68
|
try {
|
|
47
|
-
options = validateOptions(opts);
|
|
69
|
+
options = validateOptions({ ...opts, output: outputFromCli ? opts.output : void 0 });
|
|
48
70
|
} catch (err) {
|
|
49
|
-
|
|
50
|
-
process.exit(1);
|
|
71
|
+
fail(err.message);
|
|
51
72
|
}
|
|
52
|
-
const
|
|
53
|
-
let
|
|
54
|
-
let isDirectory;
|
|
73
|
+
const inputs = inputPaths.map((p) => resolveUserPath(p));
|
|
74
|
+
let plan;
|
|
55
75
|
try {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
76
|
+
plan = await planJobs({
|
|
77
|
+
inputs,
|
|
78
|
+
output: options.output,
|
|
79
|
+
format: options.format,
|
|
80
|
+
exclude: options.exclude
|
|
81
|
+
});
|
|
82
|
+
} catch (err) {
|
|
83
|
+
if (err instanceof PlanError) fail(err.message);
|
|
84
|
+
throw err;
|
|
62
85
|
}
|
|
63
|
-
|
|
64
|
-
|
|
86
|
+
const log = options.json ? () => {
|
|
87
|
+
} : console.log;
|
|
88
|
+
for (const s of plan.skipped) console.error(`Skipped ${s.file}: ${s.reason}`);
|
|
89
|
+
if (plan.jobs.length === 0) {
|
|
90
|
+
if (options.json) console.log(JSON.stringify(buildJsonOutput([], [], 0, options.output)));
|
|
91
|
+
else log("No images found to process.");
|
|
65
92
|
return;
|
|
66
93
|
}
|
|
67
94
|
if (options.dryRun) {
|
|
68
|
-
|
|
69
|
-
for (const
|
|
95
|
+
log(`Would optimize ${plan.jobs.length} image(s) into ${displayPath(options.output)}`);
|
|
96
|
+
for (const job of plan.jobs) log(` ${displayPath(job.input)} \u2192 ${displayPath(job.output)}`);
|
|
70
97
|
return;
|
|
71
98
|
}
|
|
72
|
-
const
|
|
73
|
-
const
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
...options,
|
|
83
|
-
sourcePath,
|
|
84
|
-
detectedFormat: file.format
|
|
85
|
-
});
|
|
86
|
-
if (options.verbose && !options.json) {
|
|
87
|
-
console.log(formatFileResult(result));
|
|
88
|
-
}
|
|
89
|
-
progress?.tick();
|
|
90
|
-
return result;
|
|
91
|
-
} catch (err) {
|
|
92
|
-
errors.push(err);
|
|
93
|
-
jsonErrors.push({ file: file.path, message: err.message });
|
|
94
|
-
progress?.tick();
|
|
95
|
-
return null;
|
|
96
|
-
}
|
|
97
|
-
},
|
|
98
|
-
options.concurrency
|
|
99
|
-
);
|
|
100
|
-
const validResults = processedResults.filter((r) => r !== null);
|
|
101
|
-
const totalDuration = performance.now() - startTime;
|
|
99
|
+
const showBar = !options.verbose && !options.json && process.stdout.isTTY;
|
|
100
|
+
const progress = showBar ? createProgressBar(plan.jobs.length) : null;
|
|
101
|
+
const batch = await runBatch(plan.jobs, options, options.concurrency, (job, result, error) => {
|
|
102
|
+
progress?.tick();
|
|
103
|
+
if (!options.verbose || options.json) return;
|
|
104
|
+
if (result) console.log(formatFileResult(result));
|
|
105
|
+
else console.error(`\u2717 ${displayPath(job.input)}: ${error?.message}`);
|
|
106
|
+
});
|
|
107
|
+
const maxSize = options.maxSize;
|
|
108
|
+
const exceeded = maxSize ? batch.results.filter((r) => r.newSize > maxSize) : [];
|
|
102
109
|
if (options.json) {
|
|
103
|
-
const output = buildJsonOutput(
|
|
104
|
-
if (
|
|
105
|
-
for (const
|
|
106
|
-
if (file.newSize > options.maxSize) {
|
|
107
|
-
file.exceeded = true;
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
+
const output = buildJsonOutput(batch.results, batch.errors, batch.duration, options.output);
|
|
111
|
+
if (maxSize) {
|
|
112
|
+
for (const f of output.files) if (f.newSize > maxSize) f.exceeded = true;
|
|
110
113
|
}
|
|
111
114
|
console.log(JSON.stringify(output));
|
|
112
115
|
} else {
|
|
113
|
-
console.log(formatSummary(
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
process.exit(1);
|
|
117
|
-
}
|
|
118
|
-
const maxSize = options.maxSize;
|
|
119
|
-
if (maxSize) {
|
|
120
|
-
const exceeded = validResults.filter((r) => r.newSize > maxSize);
|
|
121
|
-
if (exceeded.length > 0) {
|
|
122
|
-
if (!options.json) {
|
|
123
|
-
for (const r of exceeded) {
|
|
124
|
-
console.warn(`File exceeds size budget: ${r.outputPath}`);
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
process.exit(1);
|
|
116
|
+
console.log(formatSummary(batch.results, batch.errors, batch.duration));
|
|
117
|
+
if (!options.verbose) {
|
|
118
|
+
for (const e of batch.errors) console.error(`\u2717 ${displayPath(e.file)}: ${e.message}`);
|
|
128
119
|
}
|
|
120
|
+
if (batch.results.length > 0) console.log(`Saved to ${displayPath(options.output)}`);
|
|
121
|
+
for (const r of exceeded) console.warn(`File exceeds size budget: ${r.outputPath}`);
|
|
129
122
|
}
|
|
123
|
+
if (batch.errors.length > 0 || exceeded.length > 0) process.exitCode = 1;
|
|
130
124
|
});
|
|
125
|
+
function fail(message) {
|
|
126
|
+
console.error(`error: ${message}`);
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
131
129
|
|
|
132
130
|
// src/commands/mcp.ts
|
|
133
131
|
import { createRequire } from "module";
|
|
134
|
-
import
|
|
135
|
-
import path2 from "path";
|
|
132
|
+
import path from "path";
|
|
136
133
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
137
134
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
138
135
|
import { Command as Command2 } from "commander";
|
|
139
136
|
import { z } from "zod";
|
|
140
137
|
var require2 = createRequire(import.meta.url);
|
|
141
138
|
var { version } = require2("../../package.json");
|
|
139
|
+
function text(payload) {
|
|
140
|
+
return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
|
|
141
|
+
}
|
|
142
142
|
var mcpCommand = new Command2("mcp").description("Start MCP server for AI editor integration (Claude Code, Cursor, etc.)").action(async () => {
|
|
143
|
-
const server = new McpServer({
|
|
144
|
-
name: "bulki",
|
|
145
|
-
version
|
|
146
|
-
});
|
|
143
|
+
const server = new McpServer({ name: "bulki", version });
|
|
147
144
|
server.tool(
|
|
148
145
|
"compress",
|
|
149
|
-
"Compress and optimize images
|
|
146
|
+
"Compress and optimize images (JPEG, PNG, WebP, GIF, AVIF). Originals are never modified; optimized copies are written to `output`.",
|
|
150
147
|
{
|
|
151
148
|
path: z.string().describe("File or directory path to compress"),
|
|
152
|
-
quality: z.number().min(1).max(100).default(70).describe("Output quality (1-100)"),
|
|
149
|
+
quality: z.number().int().min(1).max(100).default(70).describe("Output quality (1-100)"),
|
|
153
150
|
format: z.enum(["jpeg", "png", "webp", "gif", "avif"]).optional().describe("Output format (omit to keep original)"),
|
|
154
|
-
resize: z.number().positive().optional().describe("Max width in pixels"),
|
|
155
|
-
|
|
156
|
-
|
|
151
|
+
resize: z.number().int().positive().optional().describe("Max width in pixels (never enlarges)"),
|
|
152
|
+
output: z.string().optional().describe("Folder where optimized images are written (default: ./bulki-output)"),
|
|
153
|
+
destination: z.string().optional().describe("Deprecated: base folder, use `output`"),
|
|
154
|
+
outputFolder: z.string().optional().describe("Deprecated: subfolder name, use `output`"),
|
|
157
155
|
maxSize: z.string().optional().describe("Max file size budget (e.g., 200kb, 1mb)")
|
|
158
156
|
},
|
|
159
157
|
async (params) => {
|
|
160
|
-
if (params.outputFolder.includes("/") || params.outputFolder.includes("\\") || params.outputFolder.includes("..")) {
|
|
161
|
-
return {
|
|
162
|
-
content: [
|
|
163
|
-
{
|
|
164
|
-
type: "text",
|
|
165
|
-
text: JSON.stringify({
|
|
166
|
-
error: "Invalid outputFolder: must be a simple directory name"
|
|
167
|
-
})
|
|
168
|
-
}
|
|
169
|
-
]
|
|
170
|
-
};
|
|
171
|
-
}
|
|
172
|
-
const resolvedPath = path2.resolve(params.path);
|
|
173
|
-
let filePaths;
|
|
174
|
-
let isDirectory;
|
|
175
158
|
try {
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
}
|
|
189
|
-
if (filePaths.length === 0) {
|
|
190
|
-
return {
|
|
191
|
-
content: [
|
|
192
|
-
{
|
|
193
|
-
type: "text",
|
|
194
|
-
text: JSON.stringify({ files: [], summary: { totalFiles: 0 } })
|
|
195
|
-
}
|
|
196
|
-
]
|
|
197
|
-
};
|
|
198
|
-
}
|
|
199
|
-
const sourcePath = isDirectory ? resolvedPath : path2.dirname(resolvedPath);
|
|
200
|
-
const startTime = performance.now();
|
|
201
|
-
const errors = [];
|
|
202
|
-
let maxSizeBytes;
|
|
203
|
-
if (params.maxSize) {
|
|
204
|
-
try {
|
|
205
|
-
maxSizeBytes = parseSize(params.maxSize);
|
|
206
|
-
} catch (err) {
|
|
207
|
-
return {
|
|
208
|
-
content: [
|
|
209
|
-
{
|
|
210
|
-
type: "text",
|
|
211
|
-
text: JSON.stringify({ error: err.message })
|
|
212
|
-
}
|
|
213
|
-
]
|
|
214
|
-
};
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
const processedResults = await processPool(
|
|
218
|
-
filePaths,
|
|
219
|
-
async (file) => {
|
|
220
|
-
try {
|
|
221
|
-
const result = await optimizeImage(file.path, {
|
|
222
|
-
quality: params.quality,
|
|
223
|
-
format: params.format,
|
|
224
|
-
resize: params.resize,
|
|
225
|
-
destination: params.destination,
|
|
226
|
-
outputFolder: params.outputFolder,
|
|
227
|
-
concurrency: 1,
|
|
228
|
-
exclude: [],
|
|
229
|
-
dryRun: false,
|
|
230
|
-
verbose: false,
|
|
231
|
-
json: false,
|
|
232
|
-
sourcePath,
|
|
233
|
-
detectedFormat: file.format
|
|
234
|
-
});
|
|
235
|
-
return result;
|
|
236
|
-
} catch (err) {
|
|
237
|
-
errors.push({ file: file.path, message: err.message });
|
|
238
|
-
return null;
|
|
239
|
-
}
|
|
240
|
-
},
|
|
241
|
-
Math.min(availableParallelism(), 8)
|
|
242
|
-
);
|
|
243
|
-
const results = processedResults.filter((r) => r !== null);
|
|
244
|
-
const totalDuration = performance.now() - startTime;
|
|
245
|
-
const output = buildJsonOutput(results, errors, totalDuration);
|
|
246
|
-
if (maxSizeBytes !== void 0) {
|
|
247
|
-
for (const file of output.files) {
|
|
248
|
-
if (file.newSize > maxSizeBytes) {
|
|
249
|
-
file.exceeded = true;
|
|
250
|
-
}
|
|
159
|
+
const maxSizeBytes = params.maxSize ? parseSize(params.maxSize) : void 0;
|
|
160
|
+
const output = resolveOutputDir(params);
|
|
161
|
+
const plan = await planJobs({
|
|
162
|
+
inputs: [resolveUserPath(params.path)],
|
|
163
|
+
output,
|
|
164
|
+
format: params.format
|
|
165
|
+
});
|
|
166
|
+
const settings = { quality: params.quality, format: params.format, resize: params.resize };
|
|
167
|
+
const batch = await runBatch(plan.jobs, settings, defaultConcurrency());
|
|
168
|
+
const result = buildJsonOutput(batch.results, batch.errors, batch.duration, output);
|
|
169
|
+
if (maxSizeBytes !== void 0) {
|
|
170
|
+
for (const file of result.files) if (file.newSize > maxSizeBytes) file.exceeded = true;
|
|
251
171
|
}
|
|
172
|
+
return text(result);
|
|
173
|
+
} catch (err) {
|
|
174
|
+
return text({ error: err.message });
|
|
252
175
|
}
|
|
253
|
-
return { content: [{ type: "text", text: JSON.stringify(output, null, 2) }] };
|
|
254
176
|
}
|
|
255
177
|
);
|
|
256
178
|
server.tool(
|
|
257
179
|
"detect",
|
|
258
180
|
"Detect if a file is an image and identify its format",
|
|
259
|
-
{
|
|
260
|
-
path: z.string().describe("Path to the file")
|
|
261
|
-
},
|
|
181
|
+
{ path: z.string().describe("Path to the file") },
|
|
262
182
|
async (params) => {
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
183
|
+
const format = await detectFormat(path.resolve(resolveUserPath(params.path)));
|
|
184
|
+
if (format === UNSUPPORTED) return text({ format: "heic", isImage: true, supported: false });
|
|
185
|
+
return text(format ? { format, isImage: true } : { format: null, isImage: false });
|
|
267
186
|
}
|
|
268
187
|
);
|
|
269
|
-
|
|
270
|
-
await server.connect(transport);
|
|
188
|
+
await server.connect(new StdioServerTransport());
|
|
271
189
|
});
|
|
272
190
|
|
|
273
191
|
// src/commands/pipe.ts
|
|
274
192
|
import { Command as Command3 } from "commander";
|
|
275
|
-
|
|
276
|
-
var pipeCommand = new Command3("pipe").description("Read image from stdin, write optimized image to stdout").requiredOption("-f, --format <fmt>", "Output format (jpeg, png, webp, gif, avif)").option("-q, --quality <n>", "Quality (1-100)",
|
|
277
|
-
|
|
193
|
+
var MAX_INPUT_SIZE = 100 * 1024 * 1024;
|
|
194
|
+
var pipeCommand = new Command3("pipe").description("Read image from stdin, write optimized image to stdout").requiredOption("-f, --format <fmt>", "Output format (jpeg, png, webp, gif, avif)").option("-q, --quality <n>", "Quality (1-100)", parseInteger, DEFAULTS.quality).option("-r, --resize <width>", "Max width in px (never enlarges)", parseInteger).action(async (opts) => {
|
|
195
|
+
let settings;
|
|
196
|
+
try {
|
|
197
|
+
settings = validateEncodeSettings(opts);
|
|
198
|
+
} catch (err) {
|
|
199
|
+
return fail2(err.message);
|
|
200
|
+
}
|
|
278
201
|
const chunks = [];
|
|
279
202
|
let totalSize = 0;
|
|
280
203
|
for await (const chunk of process.stdin) {
|
|
281
204
|
totalSize += chunk.length;
|
|
282
|
-
if (totalSize > MAX_INPUT_SIZE)
|
|
283
|
-
process.stderr.write("Error: input exceeds 100 MB limit\n");
|
|
284
|
-
process.exit(1);
|
|
285
|
-
}
|
|
205
|
+
if (totalSize > MAX_INPUT_SIZE) return fail2("input exceeds 100 MB limit");
|
|
286
206
|
chunks.push(chunk);
|
|
287
207
|
}
|
|
288
208
|
const input = Buffer.concat(chunks);
|
|
289
|
-
if (input.length === 0)
|
|
290
|
-
process.stderr.write("Error: no input received on stdin\n");
|
|
291
|
-
process.exit(1);
|
|
292
|
-
}
|
|
293
|
-
const format = opts.format;
|
|
294
|
-
const quality = opts.quality;
|
|
295
|
-
const resize = opts.resize;
|
|
296
|
-
if (!VALID_FORMATS.includes(format)) {
|
|
297
|
-
process.stderr.write(`Error: invalid format "${format}"
|
|
298
|
-
`);
|
|
299
|
-
process.exit(1);
|
|
300
|
-
}
|
|
301
|
-
const outputFormat = format === "jpg" ? "jpeg" : format;
|
|
302
|
-
const formatOptions = getFormatOptions(quality);
|
|
209
|
+
if (input.length === 0) return fail2("no input received on stdin");
|
|
303
210
|
try {
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
}
|
|
308
|
-
pipeline = pipeline.toFormat(
|
|
309
|
-
outputFormat,
|
|
310
|
-
formatOptions[outputFormat] ?? {}
|
|
311
|
-
);
|
|
312
|
-
const outputBuffer = await pipeline.toBuffer();
|
|
313
|
-
process.stdout.write(outputBuffer);
|
|
211
|
+
const detected = sniff(input);
|
|
212
|
+
const { data } = await encodeImage(input, detected, settings);
|
|
213
|
+
process.stdout.write(data);
|
|
314
214
|
} catch (err) {
|
|
315
|
-
|
|
316
|
-
`);
|
|
317
|
-
process.exit(1);
|
|
215
|
+
fail2(err.message);
|
|
318
216
|
}
|
|
319
217
|
});
|
|
218
|
+
function sniff(buf) {
|
|
219
|
+
if (buf[0] === 255 && buf[1] === 216) return "jpeg";
|
|
220
|
+
if (buf[0] === 137 && buf.toString("latin1", 1, 4) === "PNG") return "png";
|
|
221
|
+
if (buf.toString("latin1", 0, 4) === "RIFF" && buf.toString("latin1", 8, 12) === "WEBP")
|
|
222
|
+
return "webp";
|
|
223
|
+
if (buf.toString("latin1", 0, 3) === "GIF") return "gif";
|
|
224
|
+
if (buf.toString("latin1", 4, 8) === "ftyp") {
|
|
225
|
+
const brand = buf.toString("latin1", 8, 12);
|
|
226
|
+
return brand === "avif" || brand === "avis" ? "avif" : UNSUPPORTED;
|
|
227
|
+
}
|
|
228
|
+
return "unknown";
|
|
229
|
+
}
|
|
230
|
+
function fail2(message) {
|
|
231
|
+
process.stderr.write(`Error: ${message}
|
|
232
|
+
`);
|
|
233
|
+
process.exit(1);
|
|
234
|
+
}
|
|
320
235
|
|
|
321
236
|
// src/bin/bulki.ts
|
|
322
237
|
var require3 = createRequire2(import.meta.url);
|
|
323
238
|
var { version: version2 } = require3("../../package.json");
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
239
|
+
var args = process.argv.slice(2);
|
|
240
|
+
var COMMANDS = /* @__PURE__ */ new Set(["compress", "hook", "mcp", "pipe", "watch", "help"]);
|
|
241
|
+
var droppedPaths = args.length > 0 && !args[0].startsWith("-") && !COMMANDS.has(args[0]) ? parsePathInput(args.join(" ")) : null;
|
|
242
|
+
if (args.length === 0 || droppedPaths?.ok) {
|
|
243
|
+
const { runInteractive } = await import("../interactive-MTL4CMVQ.js");
|
|
244
|
+
await runInteractive(droppedPaths?.ok ? droppedPaths.paths : void 0);
|
|
327
245
|
} else {
|
|
328
246
|
const program = new Command4();
|
|
329
|
-
program.name("bulki").description("
|
|
247
|
+
program.name("bulki").description("Fast image optimizer. Run `bulki` with no arguments for the guided mode.").version(version2).showSuggestionAfterError();
|
|
330
248
|
program.addCommand(compressCommand);
|
|
331
249
|
program.addCommand(hookCommand);
|
|
332
250
|
program.addCommand(mcpCommand);
|
|
333
251
|
program.addCommand(pipeCommand);
|
|
334
252
|
program.addCommand(watchCommand);
|
|
335
|
-
program.
|
|
253
|
+
await program.parseAsync();
|
|
336
254
|
}
|
|
337
255
|
//# sourceMappingURL=bulki.js.map
|