@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.
@@ -0,0 +1,654 @@
1
+ /**
2
+ * Parse a crabcrop URL (or bare path) back into its pieces and
3
+ * explain each segment, mirroring the server's grammar. This is
4
+ * the canonical implementation: the playground keeps a plain-JS
5
+ * copy for its no-build static page, and both are pinned to the
6
+ * same verdict corpus (audit PL-10, P-3).
7
+ */
8
+ export const FILTER_DESCRIPTIONS = {
9
+ grayscale: "convert to grayscale",
10
+ invert: "invert the colors",
11
+ no_upscale: "never enlarge the source",
12
+ blur: "gaussian blur, argument is the sigma",
13
+ blurfaces: "blur detected faces (needs a face model on the server)",
14
+ brighten: "brighten by the given amount (-255..255)",
15
+ contrast: "contrast in percent, -100..100 (0 is unchanged, -100 is flat)",
16
+ huerotate: "rotate the hue by the given degrees",
17
+ rotate: "rotate by the given angle in degrees; the canvas expands to fit",
18
+ quality: "output quality 1..100, or `auto` for perceptual targeting",
19
+ format: "force the output format",
20
+ fill: "the background color other operations pad with",
21
+ facepad: "how much a face crop expands around the face",
22
+ pixelatefaces: "pixelate detected faces (needs a face model on the server)",
23
+ blurclass: "blur every detected object of the named class (needs a detection model on the server)",
24
+ pixelateclass: "pixelate every detected object of the named class (needs a detection model on the server)",
25
+ removebg: "remove the background (needs an AI model on the server)",
26
+ keep_profile: "keep the source color profile instead of converting to sRGB",
27
+ upscale: "AI upscale by the given factor (needs an AI model on the server)",
28
+ equalize: "histogram equalization",
29
+ saturation: "saturation factor, 0..255 (0 is grayscale, 1 is identity)",
30
+ sharpen: "sharpen by the given amount",
31
+ text: "draw a text overlay (needs a font on the server)",
32
+ rgb: "tint toward the given red/green/blue amounts",
33
+ curve: "tone curve: remap the named channel (rgb, r, g, or b) through 2 to 16 x,y control points, linearly interpolated, ends clamped",
34
+ convolution: "convolve with a 3x3 or 5x5 kernel: 9 or 25 row-major values (plain decimals, -1024..1024), then a normalize flag (0 or 1); edges replicate",
35
+ noise: "add monochrome film grain: one deterministic noise value per pixel on all channels, strength 0..100 percent of full scale (0 is a no-op)",
36
+ trim: "trim uniform borders",
37
+ dpr: "device pixel ratio the sizes are multiplied by",
38
+ watermark: "overlay a watermark image",
39
+ max_bytes: "cap the output size, lowering quality to fit",
40
+ radius: "round the corners by the given radius in pixels (one value, or four in CSS corner order); needs an alpha-capable format",
41
+ border: "paint a border of the given width and color (RRGGBB) inside the frame",
42
+ mask: "keep only the inscribed `circle` or `ellipse`; the rest becomes transparent",
43
+ sepia: "the classic warm sepia tone",
44
+ duotone: "map shadows to the first color and highlights to the second, by luminance",
45
+ monochrome: "single-hue rendering in the given color (RRGGBB)",
46
+ pixelate: "mosaic into blocks of the given pixel size, each block averaged",
47
+ flip: "mirror the image: `h` horizontal, `v` vertical",
48
+ blur_region: "gaussian-blur the rectangle (x,y,w,h) in pixels at this point in the chain (redaction)",
49
+ overlay: "composite another image over the result: (url,x,y,alpha,blend[,width %]) with blend normal, multiply, screen, or overlay",
50
+ pixelate_region: "pixelate the rectangle (x,y,w,h) in pixels at this point in the chain (redaction)",
51
+ };
52
+ const MAX_DIMENSION = 16383;
53
+ function parseDimension(part) {
54
+ let flip = false;
55
+ let digits = part;
56
+ if (digits.startsWith("-")) {
57
+ flip = true;
58
+ digits = digits.slice(1);
59
+ }
60
+ if (digits === "" || digits === "0")
61
+ return { kind: "proportional", value: 0, flip };
62
+ if (digits === "orig")
63
+ return { kind: "original", value: 0, flip };
64
+ if (!/^\+?\d+$/.test(digits))
65
+ return null;
66
+ if (BigInt(digits) > BigInt(MAX_DIMENSION))
67
+ return null;
68
+ return { kind: "pixels", value: Number(digits), flip };
69
+ }
70
+ function parseSizeSegment(seg) {
71
+ const x = seg.indexOf("x");
72
+ if (x === -1)
73
+ return null;
74
+ const w = parseDimension(seg.slice(0, x));
75
+ const h = parseDimension(seg.slice(x + 1));
76
+ if (!w || !h)
77
+ return null;
78
+ return { w, h };
79
+ }
80
+ function isExactBox(size) {
81
+ const px = (d) => d.kind === "pixels" && d.value >= 1 && !d.flip;
82
+ return px(size.w) && px(size.h);
83
+ }
84
+ function sizeBlurb(seg) {
85
+ const size = parseSizeSegment(seg);
86
+ if (!size)
87
+ return `resize per ${seg}`;
88
+ const describe = (d, axis) => d.kind === "original"
89
+ ? `the original ${axis}`
90
+ : d.kind === "proportional"
91
+ ? `proportional ${axis}`
92
+ : `${d.value}px ${axis}`;
93
+ const flips = [
94
+ size.w.flip ? "flipped horizontally" : null,
95
+ size.h.flip ? "flipped vertically" : null,
96
+ ].filter(Boolean);
97
+ return `resize to ${describe(size.w, "width")} by ${describe(size.h, "height")}${flips.length ? ` (${flips.join(", ")})` : ""}`;
98
+ }
99
+ const NO_ARG_FILTERS = new Set([
100
+ "grayscale",
101
+ "invert",
102
+ "no_upscale",
103
+ "equalize",
104
+ "removebg",
105
+ "keep_profile",
106
+ "sepia",
107
+ "blurfaces",
108
+ "pixelatefaces",
109
+ ]);
110
+ const FORMAT_NAMES = new Set(["jpeg", "jpg", "png", "webp", "jxl", "avif", "gif", "mp4", "webm"]);
111
+ const ANCHOR_NAMES = new Set([
112
+ "top-left",
113
+ "top-center",
114
+ "top-right",
115
+ "center-left",
116
+ "center",
117
+ "center-right",
118
+ "bottom-left",
119
+ "bottom-center",
120
+ "bottom-right",
121
+ ]);
122
+ function isIntIn(s, min, max) {
123
+ // Rust's integer from_str syntax: an optional sign (the
124
+ // unsigned parsers accept '+' too), then digits.
125
+ return /^[+-]?\d+$/.test(s) && BigInt(s) >= BigInt(min) && BigInt(s) <= BigInt(max);
126
+ }
127
+ const RUST_FLOAT = /^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/;
128
+ function isFloat(s) {
129
+ // Rust's f32::from_str syntax (AP-23): plain decimal with
130
+ // an optional exponent; no hex, no surrounding whitespace,
131
+ // both of which Number() would otherwise accept. The
132
+ // inf/nan spellings Rust also parses are non-finite, and
133
+ // every caller rejects non-finite values, so the verdicts
134
+ // agree without them.
135
+ return RUST_FLOAT.test(s) && Number.isFinite(Number(s));
136
+ }
137
+ const KERNEL_VALUE = /^-?\d+(\.\d+)?$/;
138
+ function isKernelValue(s) {
139
+ // The server's kernel-value grammar (url.rs
140
+ // parse_kernel_value): the rotate angle's strict decimal
141
+ // form, no exponent and no leading dot, bounded to 1024
142
+ // in absolute value.
143
+ return KERNEL_VALUE.test(s) && Math.abs(Number(s)) <= 1024;
144
+ }
145
+ function percentDecodable(s) {
146
+ try {
147
+ return decodeURIComponent(s);
148
+ }
149
+ catch {
150
+ return null;
151
+ }
152
+ }
153
+ /**
154
+ * Validate one filter against the server's rules (url.rs
155
+ * parse_filters, ported). `args` is null when the filter has
156
+ * no parentheses (empty parens count as none, like the
157
+ * server) and the raw argument string otherwise. Returns an
158
+ * error string, or null when the server would accept it.
159
+ */
160
+ function validateFilter(name, args) {
161
+ const bad = `the server rejects this filter: ${name}${args === null ? "" : `(${args})`}`;
162
+ if (NO_ARG_FILTERS.has(name)) {
163
+ return args === null ? null : bad;
164
+ }
165
+ switch (name) {
166
+ case "trim":
167
+ return args === null || isIntIn(args, 0, 255) ? null : bad;
168
+ case "blur":
169
+ case "sharpen":
170
+ return args !== null && isFloat(args) && Number(args) > 0 ? null : bad;
171
+ case "brighten":
172
+ return args !== null && isIntIn(args, -255, 255) ? null : bad;
173
+ case "huerotate":
174
+ return args !== null && isIntIn(args, -2147483648, 2147483647) ? null : bad;
175
+ case "contrast":
176
+ return args !== null && isFloat(args) && Number(args) >= -100 && Number(args) <= 100
177
+ ? null
178
+ : bad;
179
+ case "saturation":
180
+ return args !== null && isFloat(args) && Number(args) >= 0 && Number(args) <= 255
181
+ ? null
182
+ : bad;
183
+ case "quality":
184
+ return args === "auto" || (args !== null && isIntIn(args, 1, 100)) ? null : bad;
185
+ case "format":
186
+ return args !== null && FORMAT_NAMES.has(args) ? null : bad;
187
+ case "rgb": {
188
+ if (args === null)
189
+ return bad;
190
+ const parts = args.split(",");
191
+ return parts.length === 3 && parts.every((p) => isIntIn(p, -255, 255)) ? null : bad;
192
+ }
193
+ case "curve": {
194
+ if (args === null)
195
+ return bad;
196
+ const parts = args.split(",");
197
+ if (!["rgb", "r", "g", "b"].includes(parts[0]))
198
+ return bad;
199
+ const coords = parts.slice(1);
200
+ if (coords.length < 4 || coords.length > 32 || coords.length % 2 !== 0)
201
+ return bad;
202
+ if (!coords.every((p) => isIntIn(p, 0, 255)))
203
+ return bad;
204
+ for (let i = 2; i < coords.length; i += 2) {
205
+ if (Number(coords[i]) <= Number(coords[i - 2]))
206
+ return bad;
207
+ }
208
+ return null;
209
+ }
210
+ case "convolution": {
211
+ if (args === null)
212
+ return bad;
213
+ const parts = args.split(",");
214
+ if (parts.length !== 10 && parts.length !== 26)
215
+ return bad;
216
+ const flag = parts[parts.length - 1];
217
+ if (flag !== "0" && flag !== "1")
218
+ return bad;
219
+ return parts.slice(0, -1).every((p) => isKernelValue(p)) ? null : bad;
220
+ }
221
+ case "noise":
222
+ return args !== null && isIntIn(args, 0, 100) ? null : bad;
223
+ case "facepad":
224
+ return args !== null && isFloat(args) && Number(args) >= 0 && Number(args) <= 3
225
+ ? null
226
+ : bad;
227
+ case "upscale":
228
+ return args !== null && /^[+-]?\d+$/.test(args) && (Number(args) === 2 || Number(args) === 4)
229
+ ? null
230
+ : bad;
231
+ case "dpr":
232
+ return args !== null && isFloat(args) && [1, 1.5, 2, 3].includes(Number(args))
233
+ ? null
234
+ : bad;
235
+ case "rotate":
236
+ return args !== null && /^-?\d+(\.\d+)?$/.test(args) && Math.abs(Number(args)) <= 36000
237
+ ? null
238
+ : bad;
239
+ case "fill":
240
+ return args !== null && /^[0-9a-fA-F]{6}$/.test(args) ? null : bad;
241
+ case "radius": {
242
+ if (args === null)
243
+ return bad;
244
+ const parts = args.split(",");
245
+ return (parts.length === 1 || parts.length === 4) &&
246
+ parts.every((p) => isIntIn(p, 0, 16383))
247
+ ? null
248
+ : bad;
249
+ }
250
+ case "border": {
251
+ if (args === null)
252
+ return bad;
253
+ const parts = args.split(",");
254
+ return parts.length === 2 &&
255
+ isIntIn(parts[0], 1, 16383) &&
256
+ /^[0-9a-fA-F]{6}$/.test(parts[1])
257
+ ? null
258
+ : bad;
259
+ }
260
+ case "mask":
261
+ return args === "circle" || args === "ellipse" ? null : bad;
262
+ case "duotone": {
263
+ if (args === null)
264
+ return bad;
265
+ const parts = args.split(",");
266
+ return parts.length === 2 && parts.every((p) => /^[0-9a-fA-F]{6}$/.test(p)) ? null : bad;
267
+ }
268
+ case "monochrome":
269
+ return args !== null && /^[0-9a-fA-F]{6}$/.test(args) ? null : bad;
270
+ case "pixelate":
271
+ return args !== null && isIntIn(args, 1, 16383) ? null : bad;
272
+ case "flip":
273
+ return args === "h" || args === "v" ? null : bad;
274
+ case "overlay": {
275
+ if (args === null)
276
+ return bad;
277
+ // The server splits from the right (rsplitn(6, ',')):
278
+ // with six or more fields the rightmost IS the width
279
+ // and must parse, and the URL keeps any commas left
280
+ // over. Splitting any other way misreads both.
281
+ const fields = args.split(",");
282
+ if (fields.length < 5)
283
+ return bad;
284
+ let rest = fields;
285
+ if (fields.length >= 6) {
286
+ if (!isIntIn(fields[fields.length - 1], 1, 400))
287
+ return bad;
288
+ rest = fields.slice(0, -1);
289
+ }
290
+ const blend = rest[rest.length - 1];
291
+ if (!["normal", "multiply", "screen", "overlay"].includes(blend))
292
+ return bad;
293
+ if (!isIntIn(rest[rest.length - 2], 0, 100))
294
+ return bad;
295
+ if (!isIntIn(rest[rest.length - 3], -2147483648, 2147483647))
296
+ return bad;
297
+ if (!isIntIn(rest[rest.length - 4], -2147483648, 2147483647))
298
+ return bad;
299
+ const url = percentDecodable(rest.slice(0, -4).join(","));
300
+ return url !== null && url !== "" ? null : bad;
301
+ }
302
+ case "blurclass":
303
+ case "pixelateclass": {
304
+ if (args === null)
305
+ return bad;
306
+ const decoded = percentDecodable(args);
307
+ if (decoded === null)
308
+ return bad;
309
+ const name = decoded.trim();
310
+ return name.length >= 1 &&
311
+ new TextEncoder().encode(name).length <= 64 &&
312
+ /^[\p{L}\p{N} _.\-/]+$/u.test(name)
313
+ ? null
314
+ : bad;
315
+ }
316
+ case "blur_region":
317
+ case "pixelate_region": {
318
+ if (args === null)
319
+ return bad;
320
+ const parts = args.split(",");
321
+ return parts.length === 4 &&
322
+ parts.every((p) => isIntIn(p, 0, 16383)) &&
323
+ Number(parts[2]) >= 1 &&
324
+ Number(parts[3]) >= 1
325
+ ? null
326
+ : bad;
327
+ }
328
+ case "max_bytes":
329
+ return args !== null &&
330
+ /^[+-]?\d+$/.test(args) &&
331
+ BigInt(args) >= 1n &&
332
+ BigInt(args) <= 18446744073709551615n
333
+ ? null
334
+ : bad;
335
+ case "text": {
336
+ if (args === null)
337
+ return bad;
338
+ const parts = args.split(",");
339
+ if (parts.length < 4)
340
+ return bad;
341
+ const anchor = parts[parts.length - 1];
342
+ const color = parts[parts.length - 2];
343
+ const size = parts[parts.length - 3];
344
+ const text = percentDecodable(parts.slice(0, -3).join(","));
345
+ return ANCHOR_NAMES.has(anchor) &&
346
+ /^[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(color) &&
347
+ isIntIn(size, 1, 1000) &&
348
+ text !== null &&
349
+ text !== ""
350
+ ? null
351
+ : bad;
352
+ }
353
+ case "watermark": {
354
+ if (args === null)
355
+ return bad;
356
+ const parts = args.split(",");
357
+ if (parts.length < 4)
358
+ return bad;
359
+ const alpha = parts[parts.length - 1];
360
+ const y = parts[parts.length - 2];
361
+ const x = parts[parts.length - 3];
362
+ const url = percentDecodable(parts.slice(0, -3).join(","));
363
+ return isIntIn(alpha, 0, 100) &&
364
+ isIntIn(y, -2147483648, 2147483647) &&
365
+ isIntIn(x, -2147483648, 2147483647) &&
366
+ url !== null &&
367
+ url !== ""
368
+ ? null
369
+ : bad;
370
+ }
371
+ default:
372
+ return `unknown filter: ${name}`;
373
+ }
374
+ }
375
+ function explainFilters(rest) {
376
+ // The server splits the list naively on ':'; a ':' inside
377
+ // parentheses is not special there either.
378
+ const parts = rest.split(":");
379
+ const blurbs = [];
380
+ for (const part of parts) {
381
+ const match = part.match(/^([a-z_0-9]+)(?:\((.*)\))?$/);
382
+ if (!match) {
383
+ return { error: `malformed filter: ${part}` };
384
+ }
385
+ const [, name, rawArgs] = match;
386
+ const args = rawArgs === undefined || rawArgs === "" ? null : rawArgs;
387
+ const blurb = FILTER_DESCRIPTIONS[name];
388
+ if (!blurb) {
389
+ return { error: `unknown filter: ${name}` };
390
+ }
391
+ const invalid = validateFilter(name, args);
392
+ if (invalid) {
393
+ return { error: invalid };
394
+ }
395
+ blurbs.push(args !== null ? `${name}(${args}): ${blurb}` : `${name}: ${blurb}`);
396
+ }
397
+ return { detail: blurbs.join("; ") };
398
+ }
399
+ const ALIGN_NAMES = new Set(["center", "smart", "face", "top", "bottom", "left", "right", "top-left", "top-right", "bottom-left", "bottom-right"]);
400
+ const U64_MAX = 18446744073709551615n;
401
+ function explainSegment(seg, nowSeconds) {
402
+ if (seg.startsWith("exp:")) {
403
+ const value = seg.slice(4);
404
+ if (!/^\d+$/.test(value)) {
405
+ return { kind: "expiry", title: "Expiry", error: `malformed expiry: ${seg}` };
406
+ }
407
+ // Parse as BigInt: the server reads a u64, and a huge
408
+ // value must report its clean 400, not crash the date
409
+ // arithmetic (audit P-3).
410
+ const seconds = BigInt(value);
411
+ if (seconds > U64_MAX) {
412
+ return {
413
+ kind: "expiry",
414
+ title: "Expiry",
415
+ error: `expiry overflows the server's u64 parser: ${seg} (the server answers 400)`,
416
+ };
417
+ }
418
+ const expired = BigInt(nowSeconds) >= seconds;
419
+ const asNumber = Number(seconds);
420
+ const when = asNumber <= 8640000000000
421
+ ? new Date(asNumber * 1000).toISOString().replace("T", " ").replace(".000Z", " UTC")
422
+ : "a date beyond representation";
423
+ return {
424
+ kind: "expiry",
425
+ title: "Expiry",
426
+ detail: `valid strictly before ${when} (${value})${expired ? " — EXPIRED at the reference time" : ""}`,
427
+ expired,
428
+ };
429
+ }
430
+ if (seg.startsWith("maxage:")) {
431
+ const value = seg.slice(7);
432
+ if (!/^\d+$/.test(value)) {
433
+ return {
434
+ kind: "maxage",
435
+ title: "Max-age",
436
+ error: `malformed maxage: ${seg} (want maxage:<seconds> from 0 to 31536000)`,
437
+ };
438
+ }
439
+ if (BigInt(value) > 31536000n) {
440
+ return {
441
+ kind: "maxage",
442
+ title: "Max-age",
443
+ error: `maxage out of range: ${seg} (0 to 31536000 seconds; the server answers 400)`,
444
+ };
445
+ }
446
+ return {
447
+ kind: "maxage",
448
+ title: "Max-age",
449
+ detail: value === "0"
450
+ ? "this response is served with Cache-Control: public, max-age=0 and no immutable flag: caches must revalidate before reuse; the stored image is unchanged"
451
+ : `this response is served with Cache-Control max-age ${value} seconds instead of the default year; the stored image is unchanged`,
452
+ };
453
+ }
454
+ if (seg.startsWith("debug:")) {
455
+ const value = seg.slice(6);
456
+ if (value !== "focal") {
457
+ return {
458
+ kind: "debug",
459
+ title: "Debug view",
460
+ error: `unsupported debug view: ${seg} (want debug:focal; the server answers 400)`,
461
+ };
462
+ }
463
+ return {
464
+ kind: "debug",
465
+ title: "Debug view",
466
+ detail: "the response is a diagnostic image instead of the transform result: the canvas the next crop operates on, with the chosen focal point (magenta crosshair), the focal rectangle when one is in play, detected face boxes (amber), and the crop window outline (blue); with no crop it shows the focal analysis of the final image; debug responses are never cached and are served with Cache-Control: no-store; animated sources and video formats are refused with 400",
467
+ };
468
+ }
469
+ if (seg.startsWith("preset:")) {
470
+ const name = seg.slice(7);
471
+ if (!name) {
472
+ return { kind: "preset", title: "Preset", error: "empty preset name" };
473
+ }
474
+ return {
475
+ kind: "preset",
476
+ title: "Preset",
477
+ detail: `expands server-side to the operations the operator named "${name}"; the signature covers this reference, not the expansion`,
478
+ };
479
+ }
480
+ if (seg.startsWith("crop-rect:")) {
481
+ const rest = seg.slice(10);
482
+ const m = rest.match(/^([+-]?\d+)x([+-]?\d+):([+-]?\d+)x([+-]?\d+)$/);
483
+ if (!m ||
484
+ m.slice(1).some((n) => BigInt(n) < 0n || BigInt(n) > 4294967295n) ||
485
+ BigInt(m[3]) <= BigInt(m[1]) ||
486
+ BigInt(m[4]) <= BigInt(m[2])) {
487
+ return {
488
+ kind: "operation",
489
+ title: "Crop rectangle",
490
+ error: `malformed crop-rect segment: ${seg} (want crop-rect:<l>x<t>:<r>x<b> with right > left and bottom > top)`,
491
+ };
492
+ }
493
+ return { kind: "operation", title: "Crop rectangle", detail: `crop the exact rectangle ${rest} (left x top : right x bottom)` };
494
+ }
495
+ if (seg.startsWith("focal:")) {
496
+ const rest = seg.slice(6);
497
+ const m = rest.match(/^([+-]?\d+)x([+-]?\d+):([+-]?\d+)x([+-]?\d+)$/);
498
+ if (!m ||
499
+ m.slice(1).some((n) => BigInt(n) < 0n || BigInt(n) > 4294967295n) ||
500
+ BigInt(m[3]) <= BigInt(m[1]) ||
501
+ BigInt(m[4]) <= BigInt(m[2])) {
502
+ return {
503
+ kind: "operation",
504
+ title: "Focal rectangle",
505
+ error: `malformed focal segment: ${seg} (want focal:<l>x<t>:<r>x<b> with right > left and bottom > top)`,
506
+ };
507
+ }
508
+ return { kind: "operation", title: "Focal rectangle", detail: `set the focal rectangle ${rest} for the next crop (left x top : right x bottom)` };
509
+ }
510
+ if (seg.startsWith("crop:")) {
511
+ const rest = seg.slice(5);
512
+ const idx = rest.indexOf(":");
513
+ const dims = idx === -1 ? rest : rest.slice(0, idx);
514
+ const align = idx === -1 ? "center" : rest.slice(idx + 1);
515
+ const cropSize = parseSizeSegment(dims);
516
+ if (!cropSize || !isExactBox(cropSize) || !ALIGN_NAMES.has(align)) {
517
+ return {
518
+ kind: "operation",
519
+ title: "Crop",
520
+ error: `malformed crop segment: ${seg} (want crop:<w>x<h>[:center|smart|face|top|bottom|left|right|top-left|top-right|bottom-left|bottom-right], pixels only, no flips)`,
521
+ };
522
+ }
523
+ return { kind: "operation", title: "Crop", detail: `crop to the aspect of ${dims} and resize to it; alignment: ${align === "center" ? "center (or the focal point for smart)" : align}` };
524
+ }
525
+ if (seg.startsWith("fit-in:")) {
526
+ const rest = seg.slice(7);
527
+ const idx = rest.indexOf(":");
528
+ const dims = idx === -1 ? rest : rest.slice(0, idx);
529
+ const modifier = idx === -1 ? null : rest.slice(idx + 1);
530
+ const size = parseSizeSegment(dims);
531
+ if (!size || size.w.flip || size.h.flip || (modifier !== null && modifier !== "adaptive")) {
532
+ return { kind: "operation", title: "Fit in", error: `malformed fit-in segment: ${seg} (want fit-in:<w>x<h>[:adaptive], flips are not allowed here)` };
533
+ }
534
+ if (modifier === "adaptive") {
535
+ return { kind: "operation", title: "Fit inside a box", detail: `scale proportionally to fit inside ${dims}, swapping the box axes when the source orientation disagrees (adaptive)` };
536
+ }
537
+ return { kind: "operation", title: "Fit inside a box", detail: `scale proportionally to fit inside ${rest}` };
538
+ }
539
+ if (seg.startsWith("pad:")) {
540
+ const rest = seg.slice(4);
541
+ const idx = rest.indexOf(":");
542
+ const dims = idx === -1 ? rest : rest.slice(0, idx);
543
+ const fill = idx === -1 ? null : rest.slice(idx + 1);
544
+ const padSize = parseSizeSegment(dims);
545
+ if (!padSize || !isExactBox(padSize) || (fill !== null && fill !== "blur" && !/^[0-9a-fA-F]{6}$/.test(fill))) {
546
+ return { kind: "operation", title: "Pad", error: `malformed pad segment: ${seg} (want pad:<w>x<h>[:<hex>|blur], pixels only, no flips)` };
547
+ }
548
+ return { kind: "operation", title: "Pad", detail: `fit inside ${dims} and fill the canvas to exactly that size, fill: ${fill ?? "the fill(...) color or black"}` };
549
+ }
550
+ if (seg.startsWith("filters:")) {
551
+ const { detail, error } = explainFilters(seg.slice(8));
552
+ if (error) {
553
+ return { kind: "operation", title: "Filters", error };
554
+ }
555
+ return { kind: "operation", title: "Filters", detail };
556
+ }
557
+ if (parseSizeSegment(seg)) {
558
+ return { kind: "operation", title: "Resize", detail: sizeBlurb(seg) };
559
+ }
560
+ return null; // Not an operation: the source starts here.
561
+ }
562
+ /**
563
+ * Parse a crabcrop URL (or bare path) back into its pieces,
564
+ * explaining each segment. Mirrors the server's dispatch:
565
+ * segments are operations until the first segment that is not
566
+ * one; everything from there is the source.
567
+ *
568
+ * Returns { signature, segments, source, remainder, errors }
569
+ * where segments carry { raw, kind, title, detail?, error? }.
570
+ */
571
+ export function explainUrl(input, nowSeconds = Math.floor(Date.now() / 1000)) {
572
+ // Extract the path by string surgery, never via the URL
573
+ // parser: WHATWG parsing resolves dot segments (.. and
574
+ // %2e%2e) that the server treats as literal path bytes
575
+ // (audit P-3). A query or fragment is not part of the
576
+ // signed path, on either side.
577
+ let path = input.trim();
578
+ const scheme = path.indexOf("://");
579
+ if (scheme > 0 && /^[a-zA-Z][a-zA-Z0-9+.-]*$/.test(path.slice(0, scheme))) {
580
+ const afterAuthority = path.slice(scheme + 3);
581
+ const slash = afterAuthority.indexOf("/");
582
+ path = slash === -1 ? "" : afterAuthority.slice(slash + 1);
583
+ }
584
+ else if (path.startsWith("/")) {
585
+ path = path.slice(1);
586
+ }
587
+ const cut = path.search(/[?#]/);
588
+ if (cut !== -1) {
589
+ path = path.slice(0, cut);
590
+ }
591
+ const errors = [];
592
+ const slash = path.indexOf("/");
593
+ if (slash === -1) {
594
+ return { signature: null, segments: [], source: "", remainder: "", errors: ["no signature segment: the path is a single segment"] };
595
+ }
596
+ const signatureRaw = path.slice(0, slash);
597
+ const remainder = path.slice(slash + 1);
598
+ let signature;
599
+ const keyed = signatureRaw.match(/^([A-Za-z0-9_-]{1,32}):([0-9a-f]{64})$/);
600
+ if (signatureRaw === "unsafe") {
601
+ signature = { kind: "unsafe", value: signatureRaw, detail: "unsigned; the server honors it only with CRABCROP_ALLOW_UNSAFE or in presets-only deployments" };
602
+ }
603
+ else if (keyed) {
604
+ signature = { kind: "hmac", value: keyed[2], keyId: keyed[1], detail: `HMAC-SHA256 of the remainder, signed with key "${keyed[1]}"; the server verifies it against that key only (secret rotation)` };
605
+ }
606
+ else if (/^[0-9a-f]{64}$/.test(signatureRaw)) {
607
+ signature = { kind: "hmac", value: signatureRaw, detail: "HMAC-SHA256 of the remainder; paste the secret to verify it below" };
608
+ }
609
+ else if (/^[0-9a-f]+$/.test(signatureRaw)) {
610
+ signature = { kind: "invalid", value: signatureRaw, detail: "lowercase hex, but not a full 64-character HMAC tag: the server will answer 403" };
611
+ errors.push("malformed signature segment");
612
+ }
613
+ else {
614
+ signature = { kind: "invalid", value: signatureRaw, detail: "not `unsafe` and not lowercase hex: the server rejects it as malformed (400)" };
615
+ errors.push("malformed signature segment");
616
+ }
617
+ const segments = [];
618
+ let rest = remainder;
619
+ let source = "";
620
+ for (;;) {
621
+ const idx = rest.indexOf("/");
622
+ const seg = idx === -1 ? rest : rest.slice(0, idx);
623
+ if (seg === "" && idx !== -1) {
624
+ // An empty segment in the operations run (a doubled
625
+ // slash): the server answers 400 (missing source URL)
626
+ // instead of treating the rest as a source with a
627
+ // leading slash (AP-23).
628
+ const error = "empty segment in the path: the server answers 400 (missing source URL)";
629
+ segments.push({ raw: seg, kind: "operation", title: "Empty segment", error });
630
+ errors.push(error);
631
+ rest = rest.slice(idx + 1);
632
+ continue;
633
+ }
634
+ const explained = seg === "" ? null : explainSegment(seg, nowSeconds);
635
+ if (!explained) {
636
+ source = rest;
637
+ break;
638
+ }
639
+ segments.push({ raw: seg, ...explained });
640
+ if (explained.error) {
641
+ errors.push(explained.error);
642
+ }
643
+ if (idx === -1) {
644
+ errors.push("the URL ends after the operations: no source");
645
+ rest = "";
646
+ break;
647
+ }
648
+ rest = rest.slice(idx + 1);
649
+ }
650
+ if (!source && rest) {
651
+ source = rest;
652
+ }
653
+ return { signature, segments, source, remainder, errors };
654
+ }