@getwebp/mcp-server 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/README.md +77 -0
- package/dist/index.js +841 -0
- package/package.json +39 -0
package/README.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# @getwebp/mcp-server
|
|
2
|
+
|
|
3
|
+
MCP server for GetWebP — lets AI agents convert images to WebP directly from your editor or chat.
|
|
4
|
+
|
|
5
|
+
## Available Tools
|
|
6
|
+
|
|
7
|
+
| Tool | Description |
|
|
8
|
+
|------|-------------|
|
|
9
|
+
| `convert_images` | Convert images (JPEG, PNG, BMP) to WebP with configurable quality |
|
|
10
|
+
| `scan_images` | Scan a directory for convertible images and report potential savings |
|
|
11
|
+
| `get_status` | Check license status, usage limits, and remaining quota |
|
|
12
|
+
|
|
13
|
+
## Setup
|
|
14
|
+
|
|
15
|
+
### Claude Desktop
|
|
16
|
+
|
|
17
|
+
Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
|
|
18
|
+
|
|
19
|
+
```json
|
|
20
|
+
{
|
|
21
|
+
"mcpServers": {
|
|
22
|
+
"getwebp": {
|
|
23
|
+
"command": "npx",
|
|
24
|
+
"args": ["-y", "@getwebp/mcp-server"]
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### Claude Code
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
claude mcp add getwebp -- npx -y @getwebp/mcp-server
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Cursor
|
|
37
|
+
|
|
38
|
+
Add to `.cursor/mcp.json` in your project root:
|
|
39
|
+
|
|
40
|
+
```json
|
|
41
|
+
{
|
|
42
|
+
"mcpServers": {
|
|
43
|
+
"getwebp": {
|
|
44
|
+
"command": "npx",
|
|
45
|
+
"args": ["-y", "@getwebp/mcp-server"]
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Windsurf / Other MCP Clients
|
|
52
|
+
|
|
53
|
+
Use stdio transport with:
|
|
54
|
+
- **Command:** `npx`
|
|
55
|
+
- **Args:** `["-y", "@getwebp/mcp-server"]`
|
|
56
|
+
|
|
57
|
+
## Free vs Pro
|
|
58
|
+
|
|
59
|
+
| | Free | Pro |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| Conversions | 10 images / day | Unlimited |
|
|
62
|
+
| Max file size | 5 MB | 50 MB |
|
|
63
|
+
| Quality presets | Default only | Full control |
|
|
64
|
+
|
|
65
|
+
## License Activation
|
|
66
|
+
|
|
67
|
+
Activate your Pro license before starting the MCP server:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
npx @getwebp/cli auth <your-license-key>
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The MCP server reads the same local credential store as the CLI — no additional configuration needed.
|
|
74
|
+
|
|
75
|
+
## License
|
|
76
|
+
|
|
77
|
+
Proprietary. See [getwebp.com](https://getwebp.com) for details.
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,841 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
|
|
7
|
+
// ../cli/src/utils/debug.ts
|
|
8
|
+
var _debug = false;
|
|
9
|
+
var _sink = (msg) => process.stderr.write(msg);
|
|
10
|
+
function setDebugSink(sink) {
|
|
11
|
+
_sink = sink;
|
|
12
|
+
}
|
|
13
|
+
function debugLog(...args) {
|
|
14
|
+
if (_debug) _sink("[debug] " + args.join(" ") + "\n");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// src/tools/convert-images.ts
|
|
18
|
+
import path6 from "node:path";
|
|
19
|
+
import { z } from "zod";
|
|
20
|
+
|
|
21
|
+
// ../cli/src/core/license.ts
|
|
22
|
+
import jwt from "jsonwebtoken";
|
|
23
|
+
|
|
24
|
+
// ../cli/src/core/config.ts
|
|
25
|
+
import Conf from "conf";
|
|
26
|
+
import crypto2 from "node:crypto";
|
|
27
|
+
import { unlinkSync } from "node:fs";
|
|
28
|
+
import os2 from "node:os";
|
|
29
|
+
import path from "node:path";
|
|
30
|
+
|
|
31
|
+
// ../cli/src/core/machine-id.ts
|
|
32
|
+
import { execSync, exec } from "node:child_process";
|
|
33
|
+
import crypto from "node:crypto";
|
|
34
|
+
import os from "node:os";
|
|
35
|
+
function getRawId() {
|
|
36
|
+
const platform = process.platform;
|
|
37
|
+
try {
|
|
38
|
+
if (platform === "darwin") {
|
|
39
|
+
const out = execSync("ioreg -rd1 -c IOPlatformExpertDevice", { encoding: "utf8" });
|
|
40
|
+
const match = out.match(/IOPlatformUUID[^=]+=\s*"([^"]+)"/);
|
|
41
|
+
if (match) return match[1].toLowerCase();
|
|
42
|
+
} else if (platform === "linux") {
|
|
43
|
+
const out = execSync("( cat /var/lib/dbus/machine-id /etc/machine-id 2>/dev/null || hostname ) | head -n 1", {
|
|
44
|
+
encoding: "utf8",
|
|
45
|
+
shell: "/bin/sh"
|
|
46
|
+
});
|
|
47
|
+
return out.trim();
|
|
48
|
+
} else if (platform === "win32") {
|
|
49
|
+
const out = execSync(
|
|
50
|
+
"REG QUERY HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography /v MachineGuid",
|
|
51
|
+
{ encoding: "utf8" }
|
|
52
|
+
);
|
|
53
|
+
const match = out.match(/MachineGuid\s+REG_SZ\s+([^\r\n]+)/);
|
|
54
|
+
if (match) return match[1].trim();
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
}
|
|
58
|
+
return `${os.hostname()}:${os.userInfo().username}`;
|
|
59
|
+
}
|
|
60
|
+
function hashId(raw) {
|
|
61
|
+
return crypto.createHash("sha256").update(raw).digest("hex");
|
|
62
|
+
}
|
|
63
|
+
function machineIdSync() {
|
|
64
|
+
return hashId(getRawId());
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ../cli/src/core/config.ts
|
|
68
|
+
function getMachineKey() {
|
|
69
|
+
try {
|
|
70
|
+
const id = machineIdSync();
|
|
71
|
+
return crypto2.createHash("sha256").update(id).digest("hex");
|
|
72
|
+
} catch {
|
|
73
|
+
debugLog(
|
|
74
|
+
"warn: Could not read machine ID, falling back to hostname.",
|
|
75
|
+
"Token may become invalid if hostname changes."
|
|
76
|
+
);
|
|
77
|
+
return crypto2.createHash("sha256").update(`${os2.hostname()}:${os2.userInfo().username}`).digest("hex");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
var _store = null;
|
|
81
|
+
function getStore() {
|
|
82
|
+
if (!_store) {
|
|
83
|
+
const confOpts = {
|
|
84
|
+
projectName: "getwebp",
|
|
85
|
+
encryptionKey: getMachineKey(),
|
|
86
|
+
...process.env.GETWEBP_CONFIG_DIR ? { cwd: process.env.GETWEBP_CONFIG_DIR } : {}
|
|
87
|
+
};
|
|
88
|
+
try {
|
|
89
|
+
_store = new Conf(confOpts);
|
|
90
|
+
} catch {
|
|
91
|
+
debugLog("warn: Corrupt config file detected, clearing and reinitialising.");
|
|
92
|
+
const configDir = process.env.GETWEBP_CONFIG_DIR ?? (process.platform === "darwin" ? path.join(os2.homedir(), "Library", "Preferences", "getwebp-nodejs") : path.join(os2.homedir(), ".config", "getwebp"));
|
|
93
|
+
try {
|
|
94
|
+
unlinkSync(path.join(configDir, "config.json"));
|
|
95
|
+
} catch {
|
|
96
|
+
}
|
|
97
|
+
_store = new Conf(confOpts);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return _store;
|
|
101
|
+
}
|
|
102
|
+
function getConfig() {
|
|
103
|
+
return getStore().store;
|
|
104
|
+
}
|
|
105
|
+
function saveConfig(data) {
|
|
106
|
+
const store = getStore();
|
|
107
|
+
for (const [key, value] of Object.entries(data)) {
|
|
108
|
+
if (value === void 0) {
|
|
109
|
+
store.delete(key);
|
|
110
|
+
} else {
|
|
111
|
+
store.set(key, value);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ../cli/src/core/constants.ts
|
|
117
|
+
import os3 from "node:os";
|
|
118
|
+
var FREE_TIER = {
|
|
119
|
+
FILE_LIMIT: 20,
|
|
120
|
+
DELAY_MS: 3e3
|
|
121
|
+
};
|
|
122
|
+
var DEFAULTS = {
|
|
123
|
+
QUALITY: 80,
|
|
124
|
+
CONCURRENCY: Math.max(1, os3.cpus().length - 1)
|
|
125
|
+
};
|
|
126
|
+
var AUTO_QUALITY_SENTINEL = -1;
|
|
127
|
+
var NETWORK = {
|
|
128
|
+
HEARTBEAT_TIMEOUT_MS: 3e3,
|
|
129
|
+
API_TIMEOUT_MS: 5e3,
|
|
130
|
+
DRAIN_TIMEOUT_MS: 3e3
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// ../cli/src/core/heartbeat.ts
|
|
134
|
+
var _promise = null;
|
|
135
|
+
var API_BASE_URL = process.env.API_BASE_URL ?? "https://api.getwebp.com";
|
|
136
|
+
function pingHeartbeat(token) {
|
|
137
|
+
const p = fetch(`${API_BASE_URL}/v1/ping`, {
|
|
138
|
+
method: "POST",
|
|
139
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
140
|
+
signal: AbortSignal.timeout(NETWORK.HEARTBEAT_TIMEOUT_MS)
|
|
141
|
+
}).then(async (res) => {
|
|
142
|
+
if (res.status === 401 || res.status === 403) {
|
|
143
|
+
try {
|
|
144
|
+
saveConfig({ token: void 0 });
|
|
145
|
+
} catch {
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}).catch(() => {
|
|
149
|
+
}).finally(() => {
|
|
150
|
+
if (_promise === p) _promise = null;
|
|
151
|
+
});
|
|
152
|
+
_promise = p;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ../cli/src/core/license.ts
|
|
156
|
+
var API_BASE_URL2 = process.env.API_BASE_URL ?? "https://api.getwebp.com";
|
|
157
|
+
function getPublicKey() {
|
|
158
|
+
const key = "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAz41vwWfBT+qVpqXGz7sL\nzev2A4XL6Kp62fYM1vgzUUvZMfmKY9tMa2FYgasmh+l/J+5wa310ZZV9kp4cxhKc\nGC+lgTTL/sfauyzEjtY3ZDsYON0LOA6TPGYwIppsnmgYT5PS8lIsDL1gTaaskfoy\nTz3blsLS73DX6duqJxXLZxUlXrq9OdjQj9C8OYovNXgXDCc6miexDo70TCi0oHQq\n0tpKTngmAoGsGodK+7d2Etan5n8JjCQ6LnF+g8la1k1gwcyLLaTrK3cy3fGP4gAL\nDdmkyd8ELpUrebBTRtpE9Fyk979Qs4gwghx6ecpRgd3WT354/414s6K4OsElyTyT\npwIDAQAB\n-----END PUBLIC KEY-----\n";
|
|
159
|
+
if (!key) throw new Error("JWT_PUBLIC_KEY was not injected at build time");
|
|
160
|
+
return key;
|
|
161
|
+
}
|
|
162
|
+
async function checkLicense(warn) {
|
|
163
|
+
const config = getConfig();
|
|
164
|
+
if (!config?.token) return { valid: false, plan: "free" };
|
|
165
|
+
try {
|
|
166
|
+
const payload = verifyToken(config.token);
|
|
167
|
+
pingHeartbeat(config.token);
|
|
168
|
+
const exp = typeof payload === "object" && payload !== null && "exp" in payload ? payload.exp : void 0;
|
|
169
|
+
const rawPlan = typeof payload === "object" && payload !== null && "plan" in payload ? String(payload.plan) : "pro";
|
|
170
|
+
const plan = rawPlan === "starter" ? "starter" : "pro";
|
|
171
|
+
return {
|
|
172
|
+
valid: true,
|
|
173
|
+
plan,
|
|
174
|
+
expiresAt: exp ? new Date(exp * 1e3) : void 0
|
|
175
|
+
};
|
|
176
|
+
} catch (err) {
|
|
177
|
+
if (err instanceof Error && err.name === "TokenExpiredError") {
|
|
178
|
+
return { valid: false, plan: "free", expired: true };
|
|
179
|
+
}
|
|
180
|
+
if (err instanceof Error && err.message.includes("JWT_PUBLIC_KEY")) {
|
|
181
|
+
const w = warn ?? ((msg) => process.stderr.write(msg + "\n"));
|
|
182
|
+
w("warn: JWT public key not found. License validation skipped.");
|
|
183
|
+
}
|
|
184
|
+
return { valid: false, plan: "free" };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
function verifyToken(token) {
|
|
188
|
+
return jwt.verify(token, getPublicKey(), { algorithms: ["RS256"] });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ../cli/src/core/processor.ts
|
|
192
|
+
import { encode as encodeWebp, decode as decodeWebp2 } from "@jsquash/webp";
|
|
193
|
+
import { unlinkSync as unlinkSync2, constants as fsConstants } from "node:fs";
|
|
194
|
+
import fs3 from "node:fs/promises";
|
|
195
|
+
import path4 from "node:path";
|
|
196
|
+
import pLimit from "p-limit";
|
|
197
|
+
import { applySizeGuard } from "@getwebp/core/size-guard";
|
|
198
|
+
import { findOptimalQuality } from "@getwebp/core/auto-quality";
|
|
199
|
+
|
|
200
|
+
// ../cli/src/core/codecs.ts
|
|
201
|
+
import { decode as decodeJpeg } from "@jsquash/jpeg";
|
|
202
|
+
import { decode as decodePng } from "@jsquash/png";
|
|
203
|
+
import { decode as decodeWebp } from "@jsquash/webp";
|
|
204
|
+
import { decode as decodeAvifWasm } from "@jsquash/avif";
|
|
205
|
+
import Bmp from "bmp-js";
|
|
206
|
+
import fs from "node:fs/promises";
|
|
207
|
+
import path2 from "node:path";
|
|
208
|
+
async function decodeHeic(buffer) {
|
|
209
|
+
const heicDecode = (await import("heic-decode")).default;
|
|
210
|
+
const { width, height, data } = await heicDecode({ buffer: new Uint8Array(buffer) });
|
|
211
|
+
return new ImageData(
|
|
212
|
+
new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength),
|
|
213
|
+
width,
|
|
214
|
+
height
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
var decoders = {
|
|
218
|
+
".jpg": decodeJpeg,
|
|
219
|
+
".jpeg": decodeJpeg,
|
|
220
|
+
".png": decodePng,
|
|
221
|
+
".webp": decodeWebp,
|
|
222
|
+
".avif": async (buf) => {
|
|
223
|
+
const result = await decodeAvifWasm(buf);
|
|
224
|
+
if (!result) throw new Error("AVIF decode returned null");
|
|
225
|
+
return result;
|
|
226
|
+
},
|
|
227
|
+
".heic": decodeHeic,
|
|
228
|
+
".heif": decodeHeic
|
|
229
|
+
};
|
|
230
|
+
var MAGIC_VALIDATORS = {
|
|
231
|
+
".jpg": (b) => b[0] === 255 && b[1] === 216 && b[2] === 255,
|
|
232
|
+
".jpeg": (b) => b[0] === 255 && b[1] === 216 && b[2] === 255,
|
|
233
|
+
".png": (b) => b[0] === 137 && b[1] === 80 && b[2] === 78 && b[3] === 71,
|
|
234
|
+
".webp": (b) => b.length > 11 && b[8] === 87 && b[9] === 69 && b[10] === 66 && b[11] === 80,
|
|
235
|
+
".heic": (b) => b.length > 11 && b[4] === 102 && b[5] === 116 && b[6] === 121 && b[7] === 112 && (b[8] === 104 && b[9] === 101 && b[10] === 105 && b[11] === 99 || // "heic"
|
|
236
|
+
b[8] === 104 && b[9] === 101 && b[10] === 105 && b[11] === 102 || // "heif"
|
|
237
|
+
b[8] === 109 && b[9] === 105 && b[10] === 102 && b[11] === 49),
|
|
238
|
+
".heif": (b) => b.length > 11 && b[4] === 102 && b[5] === 116 && b[6] === 121 && b[7] === 112 && (b[8] === 104 && b[9] === 101 && b[10] === 105 && b[11] === 99 || b[8] === 104 && b[9] === 101 && b[10] === 105 && b[11] === 102 || b[8] === 109 && b[9] === 105 && b[10] === 102 && b[11] === 49),
|
|
239
|
+
".avif": (b) => b.length > 11 && b[4] === 102 && b[5] === 116 && b[6] === 121 && b[7] === 112 && (b[8] === 97 && b[9] === 118 && b[10] === 105 && b[11] === 102 || // "avif"
|
|
240
|
+
b[8] === 97 && b[9] === 118 && b[10] === 105 && b[11] === 115),
|
|
241
|
+
".bmp": (b) => b[0] === 66 && b[1] === 77
|
|
242
|
+
};
|
|
243
|
+
var BMP_MAX_DIMENSION = 65535;
|
|
244
|
+
async function decodeBmp(buffer) {
|
|
245
|
+
if (buffer.byteLength < 54) {
|
|
246
|
+
throw new Error("BMP file too small to contain a valid header");
|
|
247
|
+
}
|
|
248
|
+
const bmpWidth = buffer.readUInt32LE(18);
|
|
249
|
+
const bmpHeight = Math.abs(buffer.readInt32LE(22));
|
|
250
|
+
if (bmpWidth === 0 || bmpHeight === 0 || bmpWidth > BMP_MAX_DIMENSION || bmpHeight > BMP_MAX_DIMENSION) {
|
|
251
|
+
throw new Error(`BMP dimensions out of range: ${bmpWidth}x${bmpHeight}`);
|
|
252
|
+
}
|
|
253
|
+
const bmp = Bmp.decode(buffer);
|
|
254
|
+
const data = bmp.data;
|
|
255
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
256
|
+
const tmp = data[i];
|
|
257
|
+
data[i] = data[i + 2];
|
|
258
|
+
data[i + 2] = tmp;
|
|
259
|
+
}
|
|
260
|
+
return new ImageData(new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength), bmp.width, bmp.height);
|
|
261
|
+
}
|
|
262
|
+
async function decodeImage(filePath) {
|
|
263
|
+
const ext = path2.extname(filePath).toLowerCase();
|
|
264
|
+
const buffer = await fs.readFile(filePath);
|
|
265
|
+
if (buffer.byteLength === 0) {
|
|
266
|
+
throw new Error(`File is empty (0 bytes): ${filePath}`);
|
|
267
|
+
}
|
|
268
|
+
const validate = MAGIC_VALIDATORS[ext];
|
|
269
|
+
if (validate && !validate(buffer)) {
|
|
270
|
+
throw new Error(`File header does not match expected ${ext} format: ${filePath}`);
|
|
271
|
+
}
|
|
272
|
+
debugLog("decode", ext, filePath);
|
|
273
|
+
if (ext === ".bmp") {
|
|
274
|
+
try {
|
|
275
|
+
return await decodeBmp(buffer);
|
|
276
|
+
} catch (err) {
|
|
277
|
+
throw new Error(`Failed to decode ${filePath} as BMP: ${err instanceof Error ? err.message : String(err)}`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const decoder = decoders[ext];
|
|
281
|
+
if (!decoder) {
|
|
282
|
+
throw new Error(`Unsupported format: ${ext}`);
|
|
283
|
+
}
|
|
284
|
+
const arrayBuffer = buffer.byteOffset === 0 && buffer.byteLength === buffer.buffer.byteLength ? buffer.buffer : buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
|
|
285
|
+
try {
|
|
286
|
+
return await decoder(arrayBuffer);
|
|
287
|
+
} catch (err) {
|
|
288
|
+
throw new Error(`Failed to decode ${filePath} as ${ext}: ${err instanceof Error ? err.message : String(err)}`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ../cli/src/core/file-scanner.ts
|
|
293
|
+
import fs2 from "node:fs/promises";
|
|
294
|
+
import path3 from "node:path";
|
|
295
|
+
var IMAGE_EXTS = /* @__PURE__ */ new Set([".jpg", ".jpeg", ".png", ".bmp", ".webp", ".heic", ".heif", ".avif"]);
|
|
296
|
+
var defaultWarn = (msg) => process.stderr.write(msg + "\n");
|
|
297
|
+
async function collectImageFiles(input, recursive, warn = defaultWarn) {
|
|
298
|
+
const lstat = await fs2.lstat(input);
|
|
299
|
+
if (lstat.isSymbolicLink()) {
|
|
300
|
+
warn(`Warning: ${input} is a symlink, skipping`);
|
|
301
|
+
return [];
|
|
302
|
+
}
|
|
303
|
+
if (lstat.isFile()) return [input];
|
|
304
|
+
let entries;
|
|
305
|
+
try {
|
|
306
|
+
entries = await fs2.readdir(input, { withFileTypes: true });
|
|
307
|
+
} catch {
|
|
308
|
+
warn(`Warning: cannot read directory ${input}, skipping`);
|
|
309
|
+
return [];
|
|
310
|
+
}
|
|
311
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
312
|
+
const results = [];
|
|
313
|
+
const subdirPromises = [];
|
|
314
|
+
for (const entry of entries) {
|
|
315
|
+
const fullPath = path3.join(input, entry.name);
|
|
316
|
+
if (entry.isDirectory() && recursive && !entry.isSymbolicLink()) {
|
|
317
|
+
subdirPromises.push(collectImageFiles(fullPath, recursive, warn));
|
|
318
|
+
} else if (entry.isFile() && !entry.isSymbolicLink() && IMAGE_EXTS.has(path3.extname(entry.name).toLowerCase())) {
|
|
319
|
+
results.push(fullPath);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
const subResults = await Promise.all(subdirPromises);
|
|
323
|
+
results.push(...subResults.flat());
|
|
324
|
+
return results.sort();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ../cli/src/core/wasm-init.ts
|
|
328
|
+
import { readFileSync } from "node:fs";
|
|
329
|
+
import { createRequire } from "node:module";
|
|
330
|
+
import { init as initPngCodec } from "@jsquash/png/decode.js";
|
|
331
|
+
import { init as initJpegDec } from "@jsquash/jpeg/decode.js";
|
|
332
|
+
import { init as initWebpDec } from "@jsquash/webp/decode.js";
|
|
333
|
+
import { init as initWebpEnc } from "@jsquash/webp/encode.js";
|
|
334
|
+
import { init as initAvifDec } from "@jsquash/avif/decode.js";
|
|
335
|
+
var _require = createRequire(import.meta.url);
|
|
336
|
+
var initialized = false;
|
|
337
|
+
async function initWasm() {
|
|
338
|
+
if (initialized) return;
|
|
339
|
+
initialized = true;
|
|
340
|
+
const pngWasm = new WebAssembly.Module(readFileSync(_require.resolve("@jsquash/png/codec/pkg/squoosh_png_bg.wasm")));
|
|
341
|
+
await initPngCodec(pngWasm);
|
|
342
|
+
const jpegWasm = new WebAssembly.Module(readFileSync(_require.resolve("@jsquash/jpeg/codec/dec/mozjpeg_dec.wasm")));
|
|
343
|
+
await initJpegDec(jpegWasm);
|
|
344
|
+
const webpDecWasm = new WebAssembly.Module(readFileSync(_require.resolve("@jsquash/webp/codec/dec/webp_dec.wasm")));
|
|
345
|
+
await initWebpDec(webpDecWasm);
|
|
346
|
+
const webpEncWasm = new WebAssembly.Module(readFileSync(_require.resolve("@jsquash/webp/codec/enc/webp_enc.wasm")));
|
|
347
|
+
await initWebpEnc(webpEncWasm);
|
|
348
|
+
const avifDecWasm = new WebAssembly.Module(readFileSync(_require.resolve("@jsquash/avif/codec/dec/avif_dec.wasm")));
|
|
349
|
+
await initAvifDec(avifDecWasm);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ../cli/src/core/processor.ts
|
|
353
|
+
var HEIC_AVIF_EXTS = /* @__PURE__ */ new Set([".heic", ".heif", ".avif"]);
|
|
354
|
+
function isHeicOrAvif(filePath) {
|
|
355
|
+
return HEIC_AVIF_EXTS.has(path4.extname(filePath).toLowerCase());
|
|
356
|
+
}
|
|
357
|
+
async function processImages(options) {
|
|
358
|
+
const { input, output, quality, plan, out } = options;
|
|
359
|
+
const isFree = plan === "free";
|
|
360
|
+
const dryRun = options.dryRun ?? false;
|
|
361
|
+
const skipExisting = options.skipExisting ?? false;
|
|
362
|
+
debugLog("processImages", input, {
|
|
363
|
+
plan,
|
|
364
|
+
concurrency: options.concurrency,
|
|
365
|
+
recursive: options.recursive,
|
|
366
|
+
dryRun,
|
|
367
|
+
skipExisting
|
|
368
|
+
});
|
|
369
|
+
try {
|
|
370
|
+
await fs3.access(input);
|
|
371
|
+
} catch {
|
|
372
|
+
throw new Error(`Path not found: ${input}`);
|
|
373
|
+
}
|
|
374
|
+
await initWasm();
|
|
375
|
+
const recursive = options.recursive ?? false;
|
|
376
|
+
const files = await collectImageFiles(input, recursive, (msg) => out.warn(msg));
|
|
377
|
+
debugLog("collected", files.length, "files");
|
|
378
|
+
if (isFree && files.length > 0) {
|
|
379
|
+
out.warn("Free plan: max 20 files, 3s delay between each.");
|
|
380
|
+
out.warn("Upgrade at getwebp.com/pricing, then run: getwebp auth <your-key>");
|
|
381
|
+
}
|
|
382
|
+
const targets = isFree ? files.slice(0, FREE_TIER.FILE_LIMIT) : files;
|
|
383
|
+
const skipped = isFree ? Math.max(0, files.length - FREE_TIER.FILE_LIMIT) : 0;
|
|
384
|
+
const outputPaths = /* @__PURE__ */ new Map();
|
|
385
|
+
for (const file of targets) {
|
|
386
|
+
const dir = output ?? path4.dirname(file);
|
|
387
|
+
const outName = path4.basename(file, path4.extname(file)) + ".webp";
|
|
388
|
+
const outPath = path4.resolve(dir, outName);
|
|
389
|
+
const existing = outputPaths.get(outPath) ?? [];
|
|
390
|
+
existing.push(file);
|
|
391
|
+
outputPaths.set(outPath, existing);
|
|
392
|
+
}
|
|
393
|
+
for (const [outPath, sources] of outputPaths) {
|
|
394
|
+
if (sources.length > 1) {
|
|
395
|
+
out.warn(`Conflict: ${sources.join(", ")} all map to ${path4.basename(outPath)} \u2014 only the last processed will survive`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
if (!output && !dryRun) {
|
|
399
|
+
out.warn("No --output specified: converted files will be written next to source files.");
|
|
400
|
+
}
|
|
401
|
+
if (dryRun) {
|
|
402
|
+
const webpCount = targets.filter((f) => path4.extname(f).toLowerCase() === ".webp").length;
|
|
403
|
+
const nonWebpCount = targets.length - webpCount;
|
|
404
|
+
out.warn("Dry run \u2014 no files will be converted:");
|
|
405
|
+
for (const file of targets) {
|
|
406
|
+
out.warn(` would convert: ${file}`);
|
|
407
|
+
}
|
|
408
|
+
out.warn(`Would process ${nonWebpCount} file(s) (${webpCount} already .webp, skipped)`);
|
|
409
|
+
return { results: [], skippedByLimit: 0 };
|
|
410
|
+
}
|
|
411
|
+
if (output) {
|
|
412
|
+
await fs3.mkdir(output, { recursive: true });
|
|
413
|
+
await fs3.access(output, fsConstants.W_OK);
|
|
414
|
+
}
|
|
415
|
+
const results = [];
|
|
416
|
+
const writingFiles = /* @__PURE__ */ new Set();
|
|
417
|
+
const shouldRegisterSigint = options.registerSigint ?? true;
|
|
418
|
+
const sigintHandler = () => {
|
|
419
|
+
for (const f of writingFiles) {
|
|
420
|
+
try {
|
|
421
|
+
unlinkSync2(f);
|
|
422
|
+
} catch {
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
out.warn(`Interrupted. ${results.length} file(s) completed, ${writingFiles.size} in-progress cleaned up.`);
|
|
426
|
+
process.exit(130);
|
|
427
|
+
};
|
|
428
|
+
if (shouldRegisterSigint) process.once("SIGINT", sigintHandler);
|
|
429
|
+
try {
|
|
430
|
+
if (isFree) {
|
|
431
|
+
for (let i = 0; i < targets.length; i++) {
|
|
432
|
+
const file = targets[i];
|
|
433
|
+
if (i === 1) {
|
|
434
|
+
out.warn("\u23F1 3s cooldown (Starter/Pro: instant) \u2014 getwebp.com/pricing");
|
|
435
|
+
await sleep(FREE_TIER.DELAY_MS);
|
|
436
|
+
} else if (i > 1) {
|
|
437
|
+
out.warn("\u23F1 3s...");
|
|
438
|
+
await sleep(FREE_TIER.DELAY_MS);
|
|
439
|
+
}
|
|
440
|
+
debugLog("converting", file);
|
|
441
|
+
const result = await convertFile(file, output ?? path4.dirname(file), quality, out, skipExisting, writingFiles);
|
|
442
|
+
debugLog("done", file, result.status);
|
|
443
|
+
results.push(result);
|
|
444
|
+
}
|
|
445
|
+
} else {
|
|
446
|
+
const MAX_CONCURRENCY = 32;
|
|
447
|
+
const rawConcurrency = Number(options.concurrency ?? DEFAULTS.CONCURRENCY);
|
|
448
|
+
const concurrency = Number.isNaN(rawConcurrency) || rawConcurrency < 1 ? DEFAULTS.CONCURRENCY : Math.min(Math.floor(rawConcurrency), MAX_CONCURRENCY);
|
|
449
|
+
const hasHeicOrAvif = targets.some(isHeicOrAvif);
|
|
450
|
+
const effectiveConcurrency = hasHeicOrAvif ? 1 : concurrency;
|
|
451
|
+
const limit = pLimit(effectiveConcurrency);
|
|
452
|
+
const uniqueDirs = new Set(targets.map((file) => output ?? path4.dirname(file)));
|
|
453
|
+
await Promise.all([...uniqueDirs].map((dir) => fs3.mkdir(dir, { recursive: true })));
|
|
454
|
+
const tasks = targets.map((file) => {
|
|
455
|
+
const outputDir = output ?? path4.dirname(file);
|
|
456
|
+
return limit(async () => {
|
|
457
|
+
debugLog("converting", file);
|
|
458
|
+
const result = await convertFile(file, outputDir, quality, out, skipExisting, writingFiles);
|
|
459
|
+
debugLog("done", file, result.status);
|
|
460
|
+
return result;
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
results.push(...await Promise.all(tasks));
|
|
464
|
+
}
|
|
465
|
+
} finally {
|
|
466
|
+
if (shouldRegisterSigint) process.removeListener("SIGINT", sigintHandler);
|
|
467
|
+
}
|
|
468
|
+
out.summary(results, { skipped, totalFound: files.length, plan });
|
|
469
|
+
return { results, skippedByLimit: skipped };
|
|
470
|
+
}
|
|
471
|
+
async function convertFile(filePath, outputDir, quality, out, skipExisting, writingFiles) {
|
|
472
|
+
const outName = path4.basename(filePath, path4.extname(filePath)) + ".webp";
|
|
473
|
+
const outPath = path4.join(outputDir, outName);
|
|
474
|
+
if (path4.extname(filePath).toLowerCase() === ".webp" && path4.normalize(outPath) === path4.normalize(filePath)) {
|
|
475
|
+
return { file: filePath, status: "skipped", reason: "existing" };
|
|
476
|
+
}
|
|
477
|
+
if (skipExisting) {
|
|
478
|
+
try {
|
|
479
|
+
await fs3.access(outPath);
|
|
480
|
+
return { file: filePath, status: "skipped", reason: "existing" };
|
|
481
|
+
} catch {
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
const spinner = out.startFile(filePath);
|
|
485
|
+
try {
|
|
486
|
+
const inputStat = await fs3.stat(filePath);
|
|
487
|
+
let imageData;
|
|
488
|
+
try {
|
|
489
|
+
imageData = await decodeImage(filePath);
|
|
490
|
+
} catch (err) {
|
|
491
|
+
spinner.fail();
|
|
492
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
493
|
+
return { file: filePath, status: "error", error: `Decode failed: ${msg}` };
|
|
494
|
+
}
|
|
495
|
+
let webpBuffer;
|
|
496
|
+
let finalQuality;
|
|
497
|
+
let qualityMode;
|
|
498
|
+
let ssimScore;
|
|
499
|
+
try {
|
|
500
|
+
if (quality === AUTO_QUALITY_SENTINEL) {
|
|
501
|
+
const codec = {
|
|
502
|
+
encode: (img, q) => encodeWebp(img, { quality: q }),
|
|
503
|
+
decode: (buf) => decodeWebp2(buf)
|
|
504
|
+
};
|
|
505
|
+
const result = await findOptimalQuality(imageData, inputStat.size, codec);
|
|
506
|
+
finalQuality = result.quality;
|
|
507
|
+
ssimScore = result.ssim;
|
|
508
|
+
qualityMode = "auto";
|
|
509
|
+
webpBuffer = result.buffer ?? await encodeWebp(imageData, { quality: finalQuality });
|
|
510
|
+
} else {
|
|
511
|
+
finalQuality = quality;
|
|
512
|
+
qualityMode = "fixed";
|
|
513
|
+
webpBuffer = await encodeWebp(imageData, { quality });
|
|
514
|
+
}
|
|
515
|
+
} catch (err) {
|
|
516
|
+
spinner.fail();
|
|
517
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
518
|
+
return { file: filePath, status: "error", error: `Encode failed: ${msg}` };
|
|
519
|
+
}
|
|
520
|
+
const originalBuffer = (await fs3.readFile(filePath)).buffer;
|
|
521
|
+
const isWebP = path4.extname(filePath).toLowerCase() === ".webp";
|
|
522
|
+
const guard = applySizeGuard({ originalBuffer, encodedBuffer: webpBuffer, isWebP });
|
|
523
|
+
const finalBuffer = guard.buffer;
|
|
524
|
+
const tmpPath = outPath + ".tmp";
|
|
525
|
+
if (process.platform === "win32" && tmpPath.length >= 260) {
|
|
526
|
+
spinner.fail();
|
|
527
|
+
return { file: filePath, status: "error", error: `Output path too long for Windows (${tmpPath.length} chars, max 260). Use a shorter output directory or enable LongPathsEnabled in Windows registry.` };
|
|
528
|
+
}
|
|
529
|
+
writingFiles.add(tmpPath);
|
|
530
|
+
await fs3.writeFile(tmpPath, new Uint8Array(finalBuffer));
|
|
531
|
+
await fs3.rename(tmpPath, outPath);
|
|
532
|
+
writingFiles.delete(tmpPath);
|
|
533
|
+
const newSize = finalBuffer.byteLength;
|
|
534
|
+
const savedRatio = 1 - newSize / inputStat.size;
|
|
535
|
+
spinner.succeed({ quality: finalQuality, qualityMode });
|
|
536
|
+
return {
|
|
537
|
+
file: filePath,
|
|
538
|
+
originalSize: inputStat.size,
|
|
539
|
+
newSize,
|
|
540
|
+
savedRatio,
|
|
541
|
+
quality: finalQuality,
|
|
542
|
+
qualityMode,
|
|
543
|
+
ssim: ssimScore,
|
|
544
|
+
status: "success"
|
|
545
|
+
};
|
|
546
|
+
} catch (err) {
|
|
547
|
+
writingFiles.delete(outPath + ".tmp");
|
|
548
|
+
spinner.fail();
|
|
549
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
550
|
+
return { file: filePath, status: "error", error: `Write failed: ${msg}` };
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
function sleep(ms) {
|
|
554
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// src/adapters/collector-output.ts
|
|
558
|
+
var CollectorOutput = class {
|
|
559
|
+
warnings = [];
|
|
560
|
+
startFile(_file) {
|
|
561
|
+
return { succeed() {
|
|
562
|
+
}, fail() {
|
|
563
|
+
} };
|
|
564
|
+
}
|
|
565
|
+
warn(msg) {
|
|
566
|
+
this.warnings.push(msg);
|
|
567
|
+
}
|
|
568
|
+
summary(_results, _meta) {
|
|
569
|
+
}
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
// src/utils/tarpit-check.ts
|
|
573
|
+
import fs4 from "node:fs";
|
|
574
|
+
import path5 from "node:path";
|
|
575
|
+
import os4 from "node:os";
|
|
576
|
+
var USAGE_FILE = path5.join(os4.homedir(), ".getwebp", "usage.json");
|
|
577
|
+
var WINDOW_MS = 6e4;
|
|
578
|
+
var FREE_CALLS = 3;
|
|
579
|
+
var TARPIT_DELAYS = [10, 20, 30];
|
|
580
|
+
function readUsage() {
|
|
581
|
+
try {
|
|
582
|
+
const raw = fs4.readFileSync(USAGE_FILE, "utf-8");
|
|
583
|
+
const data = JSON.parse(raw);
|
|
584
|
+
if (Array.isArray(data.timestamps)) {
|
|
585
|
+
return data;
|
|
586
|
+
}
|
|
587
|
+
} catch {
|
|
588
|
+
}
|
|
589
|
+
return { timestamps: [] };
|
|
590
|
+
}
|
|
591
|
+
function writeUsage(data) {
|
|
592
|
+
const dir = path5.dirname(USAGE_FILE);
|
|
593
|
+
fs4.mkdirSync(dir, { recursive: true });
|
|
594
|
+
fs4.writeFileSync(USAGE_FILE, JSON.stringify(data, null, 2));
|
|
595
|
+
}
|
|
596
|
+
function getDelay(recentCount) {
|
|
597
|
+
if (recentCount <= FREE_CALLS) return 0;
|
|
598
|
+
const delayIndex = Math.min(recentCount - FREE_CALLS - 1, TARPIT_DELAYS.length - 1);
|
|
599
|
+
return TARPIT_DELAYS[delayIndex];
|
|
600
|
+
}
|
|
601
|
+
function checkTarpit() {
|
|
602
|
+
const now = Date.now();
|
|
603
|
+
const usage = readUsage();
|
|
604
|
+
const cutoff = now - WINDOW_MS;
|
|
605
|
+
usage.timestamps = usage.timestamps.filter((t) => t > cutoff);
|
|
606
|
+
usage.timestamps.push(now);
|
|
607
|
+
const recentCount = usage.timestamps.length;
|
|
608
|
+
const delay = getDelay(recentCount);
|
|
609
|
+
writeUsage(usage);
|
|
610
|
+
if (delay > 0) {
|
|
611
|
+
return {
|
|
612
|
+
throttled: true,
|
|
613
|
+
delay_seconds: delay,
|
|
614
|
+
recent_calls: recentCount,
|
|
615
|
+
upgrade_url: "https://getwebp.com/pricing",
|
|
616
|
+
message: `Free plan rate limit reached. ${recentCount} calls in 60s, ${delay}s cooldown required. Upgrade to Pro for unlimited instant conversions.`
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
return { throttled: false };
|
|
620
|
+
}
|
|
621
|
+
function getTarpitStatus() {
|
|
622
|
+
const now = Date.now();
|
|
623
|
+
const usage = readUsage();
|
|
624
|
+
const cutoff = now - WINDOW_MS;
|
|
625
|
+
const recentCalls = usage.timestamps.filter((t) => t > cutoff).length;
|
|
626
|
+
const nextDelay = getDelay(recentCalls + 1);
|
|
627
|
+
return { recent_calls: recentCalls, next_delay_seconds: nextDelay };
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// src/tools/convert-images.ts
|
|
631
|
+
var convertImagesSchema = z.object({
|
|
632
|
+
input: z.string().describe("Input file or directory path. Supports JPEG, PNG, BMP, WebP, HEIC, HEIF, AVIF formats."),
|
|
633
|
+
output: z.string().optional().describe("Output directory (default: write .webp next to source files)"),
|
|
634
|
+
quality: z.number().min(1).max(100).optional().default(80).describe("WebP quality 1-100 (default: 80)"),
|
|
635
|
+
recursive: z.boolean().optional().default(false).describe("Recursively process subdirectories"),
|
|
636
|
+
skip_existing: z.boolean().optional().default(false).describe("Skip files that already have a .webp version")
|
|
637
|
+
});
|
|
638
|
+
async function convertImages(params) {
|
|
639
|
+
const inputPath = path6.resolve(params.input);
|
|
640
|
+
const outputPath = params.output ? path6.resolve(params.output) : void 0;
|
|
641
|
+
const license = await checkLicense();
|
|
642
|
+
if (license.plan === "free") {
|
|
643
|
+
const tarpit = checkTarpit();
|
|
644
|
+
if (tarpit.throttled) {
|
|
645
|
+
return {
|
|
646
|
+
success: false,
|
|
647
|
+
error: "rate_limited",
|
|
648
|
+
plan: "free",
|
|
649
|
+
delay_seconds: tarpit.delay_seconds,
|
|
650
|
+
recent_calls: tarpit.recent_calls,
|
|
651
|
+
upgrade_url: tarpit.upgrade_url,
|
|
652
|
+
message: tarpit.message,
|
|
653
|
+
total: 0,
|
|
654
|
+
succeeded: 0,
|
|
655
|
+
failed: 0,
|
|
656
|
+
skipped: 0,
|
|
657
|
+
warnings: [],
|
|
658
|
+
results: []
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
const out = new CollectorOutput();
|
|
663
|
+
const fileResults = await processImages({
|
|
664
|
+
input: inputPath,
|
|
665
|
+
output: outputPath,
|
|
666
|
+
quality: params.quality,
|
|
667
|
+
plan: license.plan,
|
|
668
|
+
out,
|
|
669
|
+
recursive: params.recursive,
|
|
670
|
+
skipExisting: params.skip_existing,
|
|
671
|
+
registerSigint: false
|
|
672
|
+
});
|
|
673
|
+
const succeeded = fileResults.filter((r) => r.status === "success").length;
|
|
674
|
+
const failed = fileResults.filter((r) => r.status === "error").length;
|
|
675
|
+
const skipped = fileResults.filter((r) => r.status === "skipped").length;
|
|
676
|
+
const isFree = license.plan === "free";
|
|
677
|
+
const results = fileResults.map((r) => {
|
|
678
|
+
if (r.status === "success") {
|
|
679
|
+
return {
|
|
680
|
+
file: r.file,
|
|
681
|
+
status: "success",
|
|
682
|
+
original_size: r.originalSize,
|
|
683
|
+
new_size: r.newSize,
|
|
684
|
+
saved_ratio: r.savedRatio
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
if (r.status === "error") {
|
|
688
|
+
return { file: r.file, status: "error", error: r.error };
|
|
689
|
+
}
|
|
690
|
+
return { file: r.file, status: "skipped", reason: r.reason };
|
|
691
|
+
});
|
|
692
|
+
return {
|
|
693
|
+
success: failed === 0,
|
|
694
|
+
plan: license.plan,
|
|
695
|
+
total: fileResults.length,
|
|
696
|
+
succeeded,
|
|
697
|
+
failed,
|
|
698
|
+
skipped,
|
|
699
|
+
...isFree && fileResults.length > 0 ? { upgrade_url: "https://getwebp.com/pricing" } : {},
|
|
700
|
+
warnings: out.warnings,
|
|
701
|
+
results
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// src/tools/scan-images.ts
|
|
706
|
+
import fs5 from "node:fs/promises";
|
|
707
|
+
import path7 from "node:path";
|
|
708
|
+
import { z as z2 } from "zod";
|
|
709
|
+
var scanImagesSchema = z2.object({
|
|
710
|
+
path: z2.string().describe("Directory or file path to scan. Discovers JPEG, PNG, BMP, WebP, HEIC, HEIF, AVIF formats."),
|
|
711
|
+
recursive: z2.boolean().optional().default(false).describe("Recursively scan subdirectories")
|
|
712
|
+
});
|
|
713
|
+
function normalizeFormat(ext) {
|
|
714
|
+
if (ext === ".jpeg") return "jpg";
|
|
715
|
+
return ext.slice(1);
|
|
716
|
+
}
|
|
717
|
+
async function hasWebpSibling(filePath) {
|
|
718
|
+
const dir = path7.dirname(filePath);
|
|
719
|
+
const base = path7.basename(filePath, path7.extname(filePath));
|
|
720
|
+
const webpPath = path7.join(dir, base + ".webp");
|
|
721
|
+
try {
|
|
722
|
+
await fs5.access(webpPath);
|
|
723
|
+
return true;
|
|
724
|
+
} catch {
|
|
725
|
+
return false;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
async function scanImages(params) {
|
|
729
|
+
const targetPath = path7.resolve(params.path);
|
|
730
|
+
const warnings = [];
|
|
731
|
+
const files = await collectImageFiles(targetPath, params.recursive, (msg) => warnings.push(msg));
|
|
732
|
+
const results = await Promise.all(
|
|
733
|
+
files.map(async (filePath) => {
|
|
734
|
+
const stat = await fs5.stat(filePath);
|
|
735
|
+
const ext = path7.extname(filePath).toLowerCase();
|
|
736
|
+
return {
|
|
737
|
+
path: filePath,
|
|
738
|
+
size: stat.size,
|
|
739
|
+
format: normalizeFormat(ext),
|
|
740
|
+
has_webp: ext === ".webp" ? true : await hasWebpSibling(filePath)
|
|
741
|
+
};
|
|
742
|
+
})
|
|
743
|
+
);
|
|
744
|
+
return {
|
|
745
|
+
total: results.length,
|
|
746
|
+
files: results
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// src/tools/get-status.ts
|
|
751
|
+
async function getStatus() {
|
|
752
|
+
const license = await checkLicense();
|
|
753
|
+
const isFree = license.plan === "free";
|
|
754
|
+
const tarpit = getTarpitStatus();
|
|
755
|
+
return {
|
|
756
|
+
activated: license.valid,
|
|
757
|
+
expired: license.expired ?? false,
|
|
758
|
+
plan: license.plan,
|
|
759
|
+
expires_at: license.expiresAt?.toISOString(),
|
|
760
|
+
limits: {
|
|
761
|
+
max_files_per_run: isFree ? FREE_TIER.FILE_LIMIT : null,
|
|
762
|
+
concurrent_workers: isFree ? 1 : DEFAULTS.CONCURRENCY,
|
|
763
|
+
cooldown_seconds: isFree ? FREE_TIER.DELAY_MS / 1e3 : 0
|
|
764
|
+
},
|
|
765
|
+
tarpit_recent_calls: tarpit.recent_calls,
|
|
766
|
+
tarpit_next_delay_seconds: tarpit.next_delay_seconds
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// src/index.ts
|
|
771
|
+
if (typeof globalThis.ImageData === "undefined") {
|
|
772
|
+
globalThis.ImageData = class ImageData {
|
|
773
|
+
data;
|
|
774
|
+
width;
|
|
775
|
+
height;
|
|
776
|
+
colorSpace = "srgb";
|
|
777
|
+
constructor(data, width, height) {
|
|
778
|
+
this.data = data;
|
|
779
|
+
this.width = width;
|
|
780
|
+
this.height = height ?? data.length / 4 / width;
|
|
781
|
+
}
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
setDebugSink(() => {
|
|
785
|
+
});
|
|
786
|
+
var server = new McpServer({
|
|
787
|
+
name: "getwebp",
|
|
788
|
+
version: "0.1.0"
|
|
789
|
+
});
|
|
790
|
+
server.tool(
|
|
791
|
+
"convert_images",
|
|
792
|
+
"Convert images (JPG, PNG, BMP, WebP) to optimized WebP format. Free plan: max 10 files per run with 3s delay between each (may take 30s+). Paid plans: unlimited files with parallel processing.",
|
|
793
|
+
{
|
|
794
|
+
input: convertImagesSchema.shape.input,
|
|
795
|
+
output: convertImagesSchema.shape.output,
|
|
796
|
+
quality: convertImagesSchema.shape.quality,
|
|
797
|
+
recursive: convertImagesSchema.shape.recursive,
|
|
798
|
+
skip_existing: convertImagesSchema.shape.skip_existing
|
|
799
|
+
},
|
|
800
|
+
async (params) => {
|
|
801
|
+
const result = await convertImages(params);
|
|
802
|
+
return {
|
|
803
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
804
|
+
isError: !result.success
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
);
|
|
808
|
+
server.tool(
|
|
809
|
+
"scan_images",
|
|
810
|
+
"Scan a directory for convertible image files (JPG, PNG, BMP, WebP). Returns file list with sizes, formats, and whether a .webp version already exists. Does not modify any files.",
|
|
811
|
+
{
|
|
812
|
+
path: scanImagesSchema.shape.path,
|
|
813
|
+
recursive: scanImagesSchema.shape.recursive
|
|
814
|
+
},
|
|
815
|
+
async (params) => {
|
|
816
|
+
const result = await scanImages(params);
|
|
817
|
+
return {
|
|
818
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
);
|
|
822
|
+
server.tool(
|
|
823
|
+
"get_status",
|
|
824
|
+
"Check the current getwebp license status and plan limits. Returns activation state, plan tier, expiry date, and rate limits.",
|
|
825
|
+
{},
|
|
826
|
+
async () => {
|
|
827
|
+
const result = await getStatus();
|
|
828
|
+
return {
|
|
829
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
);
|
|
833
|
+
async function main() {
|
|
834
|
+
const transport = new StdioServerTransport();
|
|
835
|
+
await server.connect(transport);
|
|
836
|
+
}
|
|
837
|
+
main().catch((err) => {
|
|
838
|
+
process.stderr.write(`Fatal: ${err instanceof Error ? err.message : String(err)}
|
|
839
|
+
`);
|
|
840
|
+
process.exit(1);
|
|
841
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@getwebp/mcp-server",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server for getwebp — convert images to WebP via AI agents",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"getwebp-mcp": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=18"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "make build-mcp",
|
|
17
|
+
"typecheck": "tsc --noEmit",
|
|
18
|
+
"test": "vitest run",
|
|
19
|
+
"test:watch": "vitest"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@jsquash/jpeg": "^1.5.0",
|
|
23
|
+
"@jsquash/png": "^3.1.0",
|
|
24
|
+
"@jsquash/webp": "^1.4.0",
|
|
25
|
+
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
26
|
+
"bmp-js": "^0.1.0",
|
|
27
|
+
"conf": "^13.0.0",
|
|
28
|
+
"jsonwebtoken": "^9.0.2",
|
|
29
|
+
"node-machine-id": "^1.1.12",
|
|
30
|
+
"p-limit": "^6.2.0",
|
|
31
|
+
"zod": "^4.0.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/jsonwebtoken": "^9.0.7",
|
|
35
|
+
"@types/node": "^22.0.0",
|
|
36
|
+
"esbuild": "^0.25.0",
|
|
37
|
+
"typescript": "^5.7.0"
|
|
38
|
+
}
|
|
39
|
+
}
|