@crabcrop/mcp 0.0.0-stage → 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 +62 -2
- package/dist/index.js +416 -0
- package/node_modules/@crabcrop/url/README.md +81 -0
- package/node_modules/@crabcrop/url/dist/explain.d.ts +42 -0
- package/node_modules/@crabcrop/url/dist/explain.js +654 -0
- package/node_modules/@crabcrop/url/dist/index.d.ts +84 -0
- package/node_modules/@crabcrop/url/dist/index.js +182 -0
- package/node_modules/@crabcrop/url/dist/operations.d.ts +178 -0
- package/node_modules/@crabcrop/url/dist/operations.js +491 -0
- package/node_modules/@crabcrop/url/package.json +31 -0
- package/package.json +48 -4
- package/server.json +1 -0
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed builders for crabcrop operation strings (AP-24).
|
|
3
|
+
*
|
|
4
|
+
* `buildURL` takes operations verbatim, by design: the string
|
|
5
|
+
* is the spec. This module is the optional typed surface over
|
|
6
|
+
* it: each call validates its arguments against the server's
|
|
7
|
+
* grammar (the same bounds the explainer enforces) and returns
|
|
8
|
+
* the operation segment in its canonical spelling, so a typed
|
|
9
|
+
* pipeline and the equivalent hand-written string produce
|
|
10
|
+
* byte-identical URLs. Validation throws at build time, like
|
|
11
|
+
* `buildSrcSet` does for widths: a value the server would 400
|
|
12
|
+
* never gets signed.
|
|
13
|
+
*/
|
|
14
|
+
const MAX_DIMENSION = 16383;
|
|
15
|
+
function fail(what, value, rule) {
|
|
16
|
+
throw new Error(`${what} must be ${rule}, got ${JSON.stringify(value)}`);
|
|
17
|
+
}
|
|
18
|
+
function intIn(what, value, min, max) {
|
|
19
|
+
if (!Number.isSafeInteger(value) || value < min || value > max) {
|
|
20
|
+
fail(what, value, `an integer from ${min} to ${max}`);
|
|
21
|
+
}
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
function floatIn(what, value, min, max) {
|
|
25
|
+
if (!Number.isFinite(value) || value < min || value > max) {
|
|
26
|
+
fail(what, value, `a number from ${min} to ${max}`);
|
|
27
|
+
}
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
function positiveFloat(what, value) {
|
|
31
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
32
|
+
fail(what, value, "a positive number");
|
|
33
|
+
}
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The canonical number spelling: integers without a fraction,
|
|
38
|
+
* other values in plain decimal (the grammar takes no
|
|
39
|
+
* exponent forms). JavaScript's shortest round-trip spelling
|
|
40
|
+
* agrees with the server's for the magnitudes the grammar
|
|
41
|
+
* allows; exponent spellings are expanded to plain decimal.
|
|
42
|
+
*/
|
|
43
|
+
function num(value) {
|
|
44
|
+
if (Number.isSafeInteger(value))
|
|
45
|
+
return String(value);
|
|
46
|
+
const s = String(value);
|
|
47
|
+
if (!s.includes("e"))
|
|
48
|
+
return s;
|
|
49
|
+
return value
|
|
50
|
+
.toFixed(12)
|
|
51
|
+
.replace(/0+$/, "")
|
|
52
|
+
.replace(/\.$/, "");
|
|
53
|
+
}
|
|
54
|
+
function dimension(what, value) {
|
|
55
|
+
if (value === "orig")
|
|
56
|
+
return "orig";
|
|
57
|
+
if (!Number.isSafeInteger(value) || Math.abs(value) > MAX_DIMENSION) {
|
|
58
|
+
fail(what, value, `a pixel count up to ${MAX_DIMENSION}, 0, or "orig"`);
|
|
59
|
+
}
|
|
60
|
+
return String(value);
|
|
61
|
+
}
|
|
62
|
+
function pixelBox(what, width, height) {
|
|
63
|
+
intIn(`${what} width`, width, 1, MAX_DIMENSION);
|
|
64
|
+
intIn(`${what} height`, height, 1, MAX_DIMENSION);
|
|
65
|
+
return `${width}x${height}`;
|
|
66
|
+
}
|
|
67
|
+
function hex6(what, value) {
|
|
68
|
+
if (!/^[0-9a-fA-F]{6}$/.test(value)) {
|
|
69
|
+
fail(what, value, "six hex digits (RRGGBB)");
|
|
70
|
+
}
|
|
71
|
+
return value.toLowerCase();
|
|
72
|
+
}
|
|
73
|
+
function hex6or8(what, value) {
|
|
74
|
+
if (/^[0-9a-fA-F]{6}$/.test(value))
|
|
75
|
+
return `${value.toLowerCase()}ff`;
|
|
76
|
+
if (/^[0-9a-fA-F]{8}$/.test(value))
|
|
77
|
+
return value.toLowerCase();
|
|
78
|
+
return fail(what, value, "six or eight hex digits (RRGGBB or RRGGBBAA)");
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The canonical percent-encoding for filter arguments that
|
|
82
|
+
* carry text (watermark and overlay URLs, class names, text
|
|
83
|
+
* labels): everything outside the RFC 3986 unreserved set is
|
|
84
|
+
* encoded, byte-wise, exactly as the server's canonical form
|
|
85
|
+
* spells it.
|
|
86
|
+
*/
|
|
87
|
+
function encodeArg(text) {
|
|
88
|
+
return encodeURIComponent(text).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`);
|
|
89
|
+
}
|
|
90
|
+
function className(what, name) {
|
|
91
|
+
const trimmed = name.trim();
|
|
92
|
+
if (trimmed.length < 1 ||
|
|
93
|
+
trimmed.length > 64 ||
|
|
94
|
+
!/^[\p{L}\p{N} _.\-/]+$/u.test(trimmed)) {
|
|
95
|
+
fail(what, name, "1 to 64 characters of letters, digits, space, `_`, `.`, `-`, `/`");
|
|
96
|
+
}
|
|
97
|
+
return encodeArg(name);
|
|
98
|
+
}
|
|
99
|
+
const CROP_ALIGNS = ["center", "smart", "face", "top", "bottom", "left", "right", "top-left", "top-right", "bottom-left", "bottom-right"];
|
|
100
|
+
const ANCHORS = [
|
|
101
|
+
"top-left",
|
|
102
|
+
"top-center",
|
|
103
|
+
"top-right",
|
|
104
|
+
"center-left",
|
|
105
|
+
"center",
|
|
106
|
+
"center-right",
|
|
107
|
+
"bottom-left",
|
|
108
|
+
"bottom-center",
|
|
109
|
+
"bottom-right",
|
|
110
|
+
];
|
|
111
|
+
const BLEND_MODES = ["normal", "multiply", "screen", "overlay"];
|
|
112
|
+
const CURVE_CHANNELS = ["rgb", "r", "g", "b"];
|
|
113
|
+
const IMAGE_FORMATS = ["jpeg", "jpg", "png", "webp", "jxl", "avif", "gif", "mp4", "webm"];
|
|
114
|
+
/** Operation segments, one typed call per segment kind. */
|
|
115
|
+
export const ops = {
|
|
116
|
+
/** `300x200`; each side a Dimension (0 proportional, negative flips). */
|
|
117
|
+
size(width, height) {
|
|
118
|
+
return `${dimension("width", width)}x${dimension("height", height)}`;
|
|
119
|
+
},
|
|
120
|
+
/**
|
|
121
|
+
* `fit-in:300x200`; no flips (the server rejects them here).
|
|
122
|
+
* With `adaptive`, `fit-in:300x200:adaptive`: the box axes
|
|
123
|
+
* swap when the source orientation disagrees with the box's.
|
|
124
|
+
*/
|
|
125
|
+
fitIn(width, height, adaptive = false) {
|
|
126
|
+
for (const [what, v] of [
|
|
127
|
+
["width", width],
|
|
128
|
+
["height", height],
|
|
129
|
+
]) {
|
|
130
|
+
if (typeof v === "number" && v < 0) {
|
|
131
|
+
fail(`fit-in ${what}`, v, "not a flip: fit-in takes no negative sizes");
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const base = `fit-in:${dimension("width", width)}x${dimension("height", height)}`;
|
|
135
|
+
return adaptive ? `${base}:adaptive` : base;
|
|
136
|
+
},
|
|
137
|
+
/** `crop:300x200` or `crop:300x200:smart`; an exact pixel box. */
|
|
138
|
+
crop(width, height, align = "center") {
|
|
139
|
+
if (!CROP_ALIGNS.includes(align)) {
|
|
140
|
+
fail("crop align", align, `one of ${CROP_ALIGNS.join(", ")}`);
|
|
141
|
+
}
|
|
142
|
+
const box = pixelBox("crop", width, height);
|
|
143
|
+
return align === "center" ? `crop:${box}` : `crop:${box}:${align}`;
|
|
144
|
+
},
|
|
145
|
+
/** `crop-rect:10x20:110x220` (left x top : right x bottom). */
|
|
146
|
+
cropRect(left, top, right, bottom) {
|
|
147
|
+
intIn("crop-rect left", left, 0, 4294967295);
|
|
148
|
+
intIn("crop-rect top", top, 0, 4294967295);
|
|
149
|
+
intIn("crop-rect right", right, 0, 4294967295);
|
|
150
|
+
intIn("crop-rect bottom", bottom, 0, 4294967295);
|
|
151
|
+
if (right <= left || bottom <= top) {
|
|
152
|
+
fail("crop-rect", `${left}x${top}:${right}x${bottom}`, "right > left and bottom > top");
|
|
153
|
+
}
|
|
154
|
+
return `crop-rect:${left}x${top}:${right}x${bottom}`;
|
|
155
|
+
},
|
|
156
|
+
/** `focal:10x20:110x220` (left x top : right x bottom); steers the next crop. */
|
|
157
|
+
focal(left, top, right, bottom) {
|
|
158
|
+
intIn("focal left", left, 0, 4294967295);
|
|
159
|
+
intIn("focal top", top, 0, 4294967295);
|
|
160
|
+
intIn("focal right", right, 0, 4294967295);
|
|
161
|
+
intIn("focal bottom", bottom, 0, 4294967295);
|
|
162
|
+
if (right <= left || bottom <= top) {
|
|
163
|
+
fail("focal", `${left}x${top}:${right}x${bottom}`, "right > left and bottom > top");
|
|
164
|
+
}
|
|
165
|
+
return `focal:${left}x${top}:${right}x${bottom}`;
|
|
166
|
+
},
|
|
167
|
+
/** `pad:300x200`, optionally `:blur` or a RRGGBB fill color. */
|
|
168
|
+
pad(width, height, fill) {
|
|
169
|
+
const box = pixelBox("pad", width, height);
|
|
170
|
+
if (fill === undefined)
|
|
171
|
+
return `pad:${box}`;
|
|
172
|
+
if (fill === "blur")
|
|
173
|
+
return `pad:${box}:blur`;
|
|
174
|
+
return `pad:${box}:${hex6("pad fill", fill)}`;
|
|
175
|
+
},
|
|
176
|
+
/** `preset:thumb`; expands server-side. */
|
|
177
|
+
preset(name) {
|
|
178
|
+
if (name === "" || name.includes("/") || name.includes(":")) {
|
|
179
|
+
fail("preset name", name, "a non-empty preset name without `/` or `:`");
|
|
180
|
+
}
|
|
181
|
+
return `preset:${name}`;
|
|
182
|
+
},
|
|
183
|
+
/**
|
|
184
|
+
* `maxage:60`; this response's Cache-Control lifetime in
|
|
185
|
+
* seconds (0 to 31536000), overriding the default year for
|
|
186
|
+
* this request only. Header-only: it never enters the
|
|
187
|
+
* cache key, so spellings that differ only here share one
|
|
188
|
+
* stored image.
|
|
189
|
+
*/
|
|
190
|
+
maxage(seconds) {
|
|
191
|
+
return `maxage:${intIn("maxage seconds", seconds, 0, 31536000)}`;
|
|
192
|
+
},
|
|
193
|
+
/**
|
|
194
|
+
* `debug:focal`; the smart-crop debug view. Place it
|
|
195
|
+
* before the crop it annotates: the response is a
|
|
196
|
+
* diagnostic image of the focal decision (focal point,
|
|
197
|
+
* focal rectangle, crop window drawn on the crop's
|
|
198
|
+
* canvas), never the transform result, and it is never
|
|
199
|
+
* cached (served with Cache-Control: no-store).
|
|
200
|
+
*/
|
|
201
|
+
debugFocal() {
|
|
202
|
+
return "debug:focal";
|
|
203
|
+
},
|
|
204
|
+
/** Join filter calls into one `filters:` segment. */
|
|
205
|
+
filters(...calls) {
|
|
206
|
+
if (calls.length === 0) {
|
|
207
|
+
fail("filters", calls, "at least one filter call");
|
|
208
|
+
}
|
|
209
|
+
return `filters:${calls.join(":")}`;
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
/** Join operation segments into the operations string `buildURL` takes. */
|
|
213
|
+
export function joinOps(...segments) {
|
|
214
|
+
return segments.join("/");
|
|
215
|
+
}
|
|
216
|
+
/** Filter calls for `ops.filters(...)`; each returns its canonical spelling. */
|
|
217
|
+
export const filter = {
|
|
218
|
+
grayscale() {
|
|
219
|
+
return "grayscale";
|
|
220
|
+
},
|
|
221
|
+
invert() {
|
|
222
|
+
return "invert";
|
|
223
|
+
},
|
|
224
|
+
noUpscale() {
|
|
225
|
+
return "no_upscale";
|
|
226
|
+
},
|
|
227
|
+
equalize() {
|
|
228
|
+
return "equalize";
|
|
229
|
+
},
|
|
230
|
+
removeBg() {
|
|
231
|
+
return "removebg";
|
|
232
|
+
},
|
|
233
|
+
keepProfile() {
|
|
234
|
+
return "keep_profile";
|
|
235
|
+
},
|
|
236
|
+
sepia() {
|
|
237
|
+
return "sepia";
|
|
238
|
+
},
|
|
239
|
+
blurFaces() {
|
|
240
|
+
return "blurfaces";
|
|
241
|
+
},
|
|
242
|
+
pixelateFaces() {
|
|
243
|
+
return "pixelatefaces";
|
|
244
|
+
},
|
|
245
|
+
/** Gaussian blur by sigma (positive). */
|
|
246
|
+
blur(sigma) {
|
|
247
|
+
return `blur(${num(positiveFloat("blur sigma", sigma))})`;
|
|
248
|
+
},
|
|
249
|
+
/** Unsharp-mask strength (positive). */
|
|
250
|
+
sharpen(amount) {
|
|
251
|
+
return `sharpen(${num(positiveFloat("sharpen amount", amount))})`;
|
|
252
|
+
},
|
|
253
|
+
/** Add to every channel: -255..255. */
|
|
254
|
+
brighten(amount) {
|
|
255
|
+
return `brighten(${intIn("brighten amount", amount, -255, 255)})`;
|
|
256
|
+
},
|
|
257
|
+
/** Percent contrast: -100..100 (0 unchanged, -100 flat). */
|
|
258
|
+
contrast(percent) {
|
|
259
|
+
return `contrast(${num(floatIn("contrast", percent, -100, 100))})`;
|
|
260
|
+
},
|
|
261
|
+
/** Rotate the hue by integer degrees. */
|
|
262
|
+
hueRotate(degrees) {
|
|
263
|
+
return `huerotate(${intIn("huerotate degrees", degrees, -2147483648, 2147483647)})`;
|
|
264
|
+
},
|
|
265
|
+
/** Saturation factor: 0..255 (0 grayscale, 1 identity). */
|
|
266
|
+
saturation(factor) {
|
|
267
|
+
return `saturation(${num(floatIn("saturation", factor, 0, 255))})`;
|
|
268
|
+
},
|
|
269
|
+
/** Per-channel offsets, each -255..255. */
|
|
270
|
+
rgb(r, g, b) {
|
|
271
|
+
return `rgb(${intIn("rgb red", r, -255, 255)},${intIn("rgb green", g, -255, 255)},${intIn("rgb blue", b, -255, 255)})`;
|
|
272
|
+
},
|
|
273
|
+
/**
|
|
274
|
+
* Tone curve: remap the channel through 2 to 16 [x, y]
|
|
275
|
+
* control points, each coordinate 0..255 with x strictly
|
|
276
|
+
* increasing. Between points the server interpolates
|
|
277
|
+
* linearly; inputs outside the point range clamp to the
|
|
278
|
+
* nearest end value.
|
|
279
|
+
*/
|
|
280
|
+
curve(channel, points) {
|
|
281
|
+
if (!CURVE_CHANNELS.includes(channel)) {
|
|
282
|
+
fail("curve channel", channel, `one of ${CURVE_CHANNELS.join(", ")}`);
|
|
283
|
+
}
|
|
284
|
+
if (points.length < 2 || points.length > 16) {
|
|
285
|
+
fail("curve points", points, "2 to 16 control points");
|
|
286
|
+
}
|
|
287
|
+
const flat = [];
|
|
288
|
+
let prevX = -1;
|
|
289
|
+
for (const [x, y] of points) {
|
|
290
|
+
intIn("curve point x", x, 0, 255);
|
|
291
|
+
intIn("curve point y", y, 0, 255);
|
|
292
|
+
if (x <= prevX) {
|
|
293
|
+
fail("curve points", points, "control points with strictly increasing x");
|
|
294
|
+
}
|
|
295
|
+
prevX = x;
|
|
296
|
+
flat.push(x, y);
|
|
297
|
+
}
|
|
298
|
+
return `curve(${channel},${flat.join(",")})`;
|
|
299
|
+
},
|
|
300
|
+
/**
|
|
301
|
+
* Convolution: apply a caller-supplied kernel, 9 values
|
|
302
|
+
* (3x3) or 25 (5x5) in row-major order, each a plain
|
|
303
|
+
* decimal from -1024 to 1024. With normalize set the
|
|
304
|
+
* server scales the kernel by the sum of its entries
|
|
305
|
+
* first (a zero sum applies the kernel as written). The
|
|
306
|
+
* edges replicate. `normalize` is a boolean or the 0/1
|
|
307
|
+
* flag itself.
|
|
308
|
+
*/
|
|
309
|
+
convolution(values, normalize = false) {
|
|
310
|
+
if (values.length !== 9 && values.length !== 25) {
|
|
311
|
+
fail("convolution kernel", values, "9 (3x3) or 25 (5x5) values");
|
|
312
|
+
}
|
|
313
|
+
for (const v of values) {
|
|
314
|
+
floatIn("convolution kernel value", v, -1024, 1024);
|
|
315
|
+
if (!/^-?\d+(\.\d+)?$/.test(num(v))) {
|
|
316
|
+
fail("convolution kernel value", v, "a plain decimal (the grammar takes no exponent forms)");
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (normalize !== true &&
|
|
320
|
+
normalize !== false &&
|
|
321
|
+
normalize !== 0 &&
|
|
322
|
+
normalize !== 1) {
|
|
323
|
+
fail("convolution normalize", normalize, "a boolean or 0/1");
|
|
324
|
+
}
|
|
325
|
+
const flag = normalize === true || normalize === 1 ? 1 : 0;
|
|
326
|
+
return `convolution(${values.map(num).join(",")},${flag})`;
|
|
327
|
+
},
|
|
328
|
+
/**
|
|
329
|
+
* Film grain: one noise value per pixel, added equally
|
|
330
|
+
* to all three channels (alpha untouched), the value
|
|
331
|
+
* uniform in plus or minus the given percent of full
|
|
332
|
+
* scale. The field is deterministic, seeded from the
|
|
333
|
+
* image content and the amount, so the same request
|
|
334
|
+
* always renders the same bytes; 0 is a no-op.
|
|
335
|
+
*/
|
|
336
|
+
noise(amount) {
|
|
337
|
+
return `noise(${intIn("noise amount", amount, 0, 100)})`;
|
|
338
|
+
},
|
|
339
|
+
/**
|
|
340
|
+
* Rotate by an angle in degrees (decimals allowed, no
|
|
341
|
+
* exponent spelling); multiples of 90 are exact quarter
|
|
342
|
+
* turns on the server.
|
|
343
|
+
*/
|
|
344
|
+
rotate(angle) {
|
|
345
|
+
floatIn("rotate angle", angle, -36000, 36000);
|
|
346
|
+
if (!/^-?\d+(\.\d+)?$/.test(num(angle))) {
|
|
347
|
+
fail("rotate angle", angle, "a plain decimal angle (the grammar takes no exponent forms)");
|
|
348
|
+
}
|
|
349
|
+
return `rotate(${num(angle)})`;
|
|
350
|
+
},
|
|
351
|
+
/** Trim uniform borders; tolerance 0..255, default 10. */
|
|
352
|
+
trim(tolerance = 10) {
|
|
353
|
+
return `trim(${intIn("trim tolerance", tolerance, 0, 255)})`;
|
|
354
|
+
},
|
|
355
|
+
/** Encoding quality 1..100, or `"auto"` for perceptual targeting. */
|
|
356
|
+
quality(value) {
|
|
357
|
+
if (value === "auto")
|
|
358
|
+
return "quality(auto)";
|
|
359
|
+
return `quality(${intIn("quality", value, 1, 100)})`;
|
|
360
|
+
},
|
|
361
|
+
format(format) {
|
|
362
|
+
if (!IMAGE_FORMATS.includes(format)) {
|
|
363
|
+
fail("format", format, `one of ${IMAGE_FORMATS.join(", ")}`);
|
|
364
|
+
}
|
|
365
|
+
return `format(${format})`;
|
|
366
|
+
},
|
|
367
|
+
/** The background color other operations pad with (RRGGBB). */
|
|
368
|
+
fill(color) {
|
|
369
|
+
return `fill(${hex6("fill color", color)})`;
|
|
370
|
+
},
|
|
371
|
+
/** How much a face crop expands around the face: 0..3. */
|
|
372
|
+
facePad(amount) {
|
|
373
|
+
return `facepad(${num(floatIn("facepad", amount, 0, 3))})`;
|
|
374
|
+
},
|
|
375
|
+
/** AI upscale by the given factor. */
|
|
376
|
+
upscale(factor) {
|
|
377
|
+
if (factor !== 2 && factor !== 4) {
|
|
378
|
+
fail("upscale factor", factor, "2 or 4");
|
|
379
|
+
}
|
|
380
|
+
return `upscale(${factor})`;
|
|
381
|
+
},
|
|
382
|
+
/** Device pixel ratio the sizes are multiplied by. */
|
|
383
|
+
dpr(factor) {
|
|
384
|
+
if (![1, 1.5, 2, 3].includes(factor)) {
|
|
385
|
+
fail("dpr", factor, "1, 1.5, 2, or 3");
|
|
386
|
+
}
|
|
387
|
+
return `dpr(${num(factor)})`;
|
|
388
|
+
},
|
|
389
|
+
/** Cap the output bytes, lowering quality to fit: at least 1. */
|
|
390
|
+
maxBytes(bytes) {
|
|
391
|
+
if (!Number.isSafeInteger(bytes) || bytes < 1) {
|
|
392
|
+
fail("max_bytes", bytes, "a positive integer byte budget");
|
|
393
|
+
}
|
|
394
|
+
return `max_bytes(${bytes})`;
|
|
395
|
+
},
|
|
396
|
+
/** Round the corners: one radius for all, or [tl, tr, br, bl]. */
|
|
397
|
+
radius(value) {
|
|
398
|
+
const corners = Array.isArray(value) ? value : [value, value, value, value];
|
|
399
|
+
for (const c of corners)
|
|
400
|
+
intIn("radius", c, 0, MAX_DIMENSION);
|
|
401
|
+
return `radius(${corners.join(",")})`;
|
|
402
|
+
},
|
|
403
|
+
/** Paint an opaque border inside the frame (RRGGBB). */
|
|
404
|
+
border(width, color) {
|
|
405
|
+
return `border(${intIn("border width", width, 1, MAX_DIMENSION)},${hex6("border color", color)})`;
|
|
406
|
+
},
|
|
407
|
+
mask(shape) {
|
|
408
|
+
if (shape !== "circle" && shape !== "ellipse") {
|
|
409
|
+
fail("mask shape", shape, '"circle" or "ellipse"');
|
|
410
|
+
}
|
|
411
|
+
return `mask(${shape})`;
|
|
412
|
+
},
|
|
413
|
+
duotone(shadow, highlight) {
|
|
414
|
+
return `duotone(${hex6("duotone shadow", shadow)},${hex6("duotone highlight", highlight)})`;
|
|
415
|
+
},
|
|
416
|
+
monochrome(color) {
|
|
417
|
+
return `monochrome(${hex6("monochrome color", color)})`;
|
|
418
|
+
},
|
|
419
|
+
/** Mosaic block size in pixels: 1..16383. */
|
|
420
|
+
pixelate(blockSize) {
|
|
421
|
+
return `pixelate(${intIn("pixelate size", blockSize, 1, MAX_DIMENSION)})`;
|
|
422
|
+
},
|
|
423
|
+
flip(axis) {
|
|
424
|
+
if (axis !== "h" && axis !== "v") {
|
|
425
|
+
fail("flip axis", axis, '"h" or "v"');
|
|
426
|
+
}
|
|
427
|
+
return `flip(${axis})`;
|
|
428
|
+
},
|
|
429
|
+
/** Overlay a watermark image at (x, y), opacity 0..100. */
|
|
430
|
+
watermark(url, x, y, alpha) {
|
|
431
|
+
intIn("watermark x", x, -2147483648, 2147483647);
|
|
432
|
+
intIn("watermark y", y, -2147483648, 2147483647);
|
|
433
|
+
intIn("watermark alpha", alpha, 0, 100);
|
|
434
|
+
if (url === "")
|
|
435
|
+
fail("watermark url", url, "a non-empty URL");
|
|
436
|
+
return `watermark(${encodeArg(url)},${x},${y},${alpha})`;
|
|
437
|
+
},
|
|
438
|
+
/**
|
|
439
|
+
* Composite another image over the result at (x, y) with
|
|
440
|
+
* opacity 0..100 and a blend mode; `widthPct` optionally
|
|
441
|
+
* scales it to that percent of the base width (1..400).
|
|
442
|
+
*/
|
|
443
|
+
overlay(url, x, y, alpha, blend, widthPct) {
|
|
444
|
+
intIn("overlay x", x, -2147483648, 2147483647);
|
|
445
|
+
intIn("overlay y", y, -2147483648, 2147483647);
|
|
446
|
+
intIn("overlay alpha", alpha, 0, 100);
|
|
447
|
+
if (!BLEND_MODES.includes(blend)) {
|
|
448
|
+
fail("overlay blend", blend, `one of ${BLEND_MODES.join(", ")}`);
|
|
449
|
+
}
|
|
450
|
+
if (url === "")
|
|
451
|
+
fail("overlay url", url, "a non-empty URL");
|
|
452
|
+
const width = widthPct === undefined ? "" : `,${intIn("overlay width", widthPct, 1, 400)}`;
|
|
453
|
+
return `overlay(${encodeArg(url)},${x},${y},${alpha},${blend}${width})`;
|
|
454
|
+
},
|
|
455
|
+
/**
|
|
456
|
+
* Draw a text overlay. Color is RRGGBB or RRGGBBAA; the
|
|
457
|
+
* canonical spelling is always eight lowercase hex digits.
|
|
458
|
+
*/
|
|
459
|
+
text(content, size, color, anchor) {
|
|
460
|
+
if (!ANCHORS.includes(anchor)) {
|
|
461
|
+
fail("text anchor", anchor, `one of ${ANCHORS.join(", ")}`);
|
|
462
|
+
}
|
|
463
|
+
if (content === "")
|
|
464
|
+
fail("text", content, "a non-empty label");
|
|
465
|
+
intIn("text size", size, 1, 1000);
|
|
466
|
+
return `text(${encodeArg(content)},${size},${hex6or8("text color", color)},${anchor})`;
|
|
467
|
+
},
|
|
468
|
+
/** Blur every detected object of the named class. */
|
|
469
|
+
blurClass(name) {
|
|
470
|
+
return `blurclass(${className("blurclass name", name)})`;
|
|
471
|
+
},
|
|
472
|
+
/** Pixelate every detected object of the named class. */
|
|
473
|
+
pixelateClass(name) {
|
|
474
|
+
return `pixelateclass(${className("pixelateclass name", name)})`;
|
|
475
|
+
},
|
|
476
|
+
/** Blur the rectangle (x, y, w, h) in pixels. */
|
|
477
|
+
blurRegion(x, y, w, h) {
|
|
478
|
+
return `blur_region(${region(x, y, w, h)})`;
|
|
479
|
+
},
|
|
480
|
+
/** Pixelate the rectangle (x, y, w, h) in pixels. */
|
|
481
|
+
pixelateRegion(x, y, w, h) {
|
|
482
|
+
return `pixelate_region(${region(x, y, w, h)})`;
|
|
483
|
+
},
|
|
484
|
+
};
|
|
485
|
+
function region(x, y, w, h) {
|
|
486
|
+
intIn("region x", x, 0, MAX_DIMENSION);
|
|
487
|
+
intIn("region y", y, 0, MAX_DIMENSION);
|
|
488
|
+
intIn("region width", w, 1, MAX_DIMENSION);
|
|
489
|
+
intIn("region height", h, 1, MAX_DIMENSION);
|
|
490
|
+
return `${x},${y},${w},${h}`;
|
|
491
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "Build and sign crabcrop image URLs",
|
|
3
|
+
"engines": {
|
|
4
|
+
"node": ">=22"
|
|
5
|
+
},
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"default": "./dist/index.js"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"name": "@crabcrop/url",
|
|
14
|
+
"keywords": ["image", "url", "signing", "hmac", "cdn", "crabcrop"],
|
|
15
|
+
"files": ["dist"],
|
|
16
|
+
"publishConfig": { "access": "public" },
|
|
17
|
+
"repository": { "type": "git", "url": "git+https://github.com/dayvson/crabcrop.git", "directory": "sdks/typescript" },
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc",
|
|
20
|
+
"prepack": "npm run build",
|
|
21
|
+
"test": "node --test"
|
|
22
|
+
},
|
|
23
|
+
"type": "module",
|
|
24
|
+
"version": "0.1.0",
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/node": "^26.6.5",
|
|
27
|
+
"typescript": "^5.9.3"
|
|
28
|
+
},
|
|
29
|
+
"main": "./dist/index.js",
|
|
30
|
+
"types": "./dist/index.d.ts"
|
|
31
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,50 @@
|
|
|
1
1
|
{
|
|
2
|
+
"bin": {
|
|
3
|
+
"crabcrop-mcp": "dist/index.js"
|
|
4
|
+
},
|
|
5
|
+
"dependencies": {
|
|
6
|
+
"@crabcrop/url": "file:../typescript"
|
|
7
|
+
},
|
|
8
|
+
"description": "First-party MCP server for crabcrop: sign, explain, fetch meta, purge",
|
|
9
|
+
"license": "MIT",
|
|
2
10
|
"name": "@crabcrop/mcp",
|
|
3
|
-
"
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "tsc",
|
|
13
|
+
"prepack": "npm --prefix ../typescript run build && npm run build",
|
|
14
|
+
"start": "node src/index.ts",
|
|
15
|
+
"test": "node --test test/*.test.mjs"
|
|
16
|
+
},
|
|
17
|
+
"type": "module",
|
|
18
|
+
"version": "0.1.0",
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@types/node": "^26.6.5",
|
|
21
|
+
"typescript": "^5.9.3"
|
|
22
|
+
},
|
|
23
|
+
"main": "./dist/index.js",
|
|
24
|
+
"bundleDependencies": [
|
|
25
|
+
"@crabcrop/url"
|
|
26
|
+
],
|
|
27
|
+
"mcpName": "io.github.crabcrop/crabcrop",
|
|
28
|
+
"files": [
|
|
29
|
+
"dist",
|
|
30
|
+
"server.json"
|
|
31
|
+
],
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/dayvson/crabcrop.git",
|
|
35
|
+
"directory": "sdks/mcp"
|
|
36
|
+
},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=22"
|
|
42
|
+
},
|
|
43
|
+
"keywords": [
|
|
44
|
+
"mcp",
|
|
45
|
+
"model-context-protocol",
|
|
46
|
+
"image",
|
|
47
|
+
"cdn",
|
|
48
|
+
"crabcrop"
|
|
49
|
+
]
|
|
50
|
+
}
|
package/server.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"$schema":"https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json","description":"Sign and explain crabcrop image URLs, fetch processed-image metadata, and purge cached results.","name":"io.github.crabcrop/crabcrop","packages":[{"environmentVariables":[{"description":"Base URL of the crabcrop server the tools act on (for example https://images.example.com).","format":"string","isRequired":false,"isSecret":false,"name":"CRABCROP_BASE"},{"description":"The server's signing secret, used by sign_url and purge_result. Keep it in the environment, not in tool arguments.","format":"string","isRequired":false,"isSecret":true,"name":"CRABCROP_SECRET"}],"identifier":"@crabcrop/mcp","registryBaseUrl":"https://registry.npmjs.org","registryType":"npm","runTimeHint":"npx","transport":{"type":"stdio"},"version":"0.1.0"}],"repository":{"source":"github","url":"https://github.com/dayvson/crabcrop"},"version":"0.1.0"}
|