@socialtip/asset-proxy-url-parser 0.5.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/dist/parse.js ADDED
@@ -0,0 +1,1085 @@
1
+ import { z } from "zod/v4";
2
+ import { decryptSourceUrl } from "./crypto.js";
3
+ import { HTTPError } from "./error.js";
4
+ const resizingType = z.enum(["fit", "fill", "fill-down", "force", "auto"]);
5
+ const cpuAlgorithms = [
6
+ "nearest",
7
+ "linear",
8
+ "cubic",
9
+ "lanczos2",
10
+ "lanczos3",
11
+ ];
12
+ // TODO: support "cuvid" as a third GPU scaler option (uses decoder-level resize via -resize flag)
13
+ const gpuScalers = ["scale_cuda", "scale_npp"];
14
+ const cpuAlgorithmSet = new Set(cpuAlgorithms);
15
+ const gpuScalerSet = new Set(gpuScalers);
16
+ const resizingAlgorithmSchema = z.union([
17
+ z.object({
18
+ mode: z.literal("cpu"),
19
+ algorithm: z.enum(cpuAlgorithms),
20
+ }),
21
+ z.object({
22
+ mode: z.literal("gpu"),
23
+ scaler: z.enum(gpuScalers),
24
+ algorithm: z.enum(cpuAlgorithms).optional(),
25
+ }),
26
+ ]);
27
+ const zResizingAlgorithm = z.string().transform((v) => {
28
+ if (v === "gpu") {
29
+ return { mode: "gpu", scaler: "scale_cuda" };
30
+ }
31
+ if (v.startsWith("gpu:")) {
32
+ const parts = v.slice(4).split(":");
33
+ const scaler = parts[0] || "scale_cuda";
34
+ if (!gpuScalerSet.has(scaler)) {
35
+ throw new HTTPError(`Invalid GPU scaler '${scaler}': expected one of ${gpuScalers.join(", ")}`, { code: "BAD_REQUEST" });
36
+ }
37
+ const algo = parts[1];
38
+ if (algo !== undefined) {
39
+ if (!cpuAlgorithmSet.has(algo)) {
40
+ throw new HTTPError(`Invalid interpolation algorithm '${algo}': expected one of ${cpuAlgorithms.join(", ")}`, { code: "BAD_REQUEST" });
41
+ }
42
+ if (scaler !== "scale_npp") {
43
+ throw new HTTPError("Interpolation algorithm is only supported with scale_npp", { code: "BAD_REQUEST" });
44
+ }
45
+ return {
46
+ mode: "gpu",
47
+ scaler: scaler,
48
+ algorithm: algo,
49
+ };
50
+ }
51
+ return { mode: "gpu", scaler: scaler };
52
+ }
53
+ if (!cpuAlgorithmSet.has(v)) {
54
+ throw new HTTPError(`Invalid resizing algorithm '${v}': expected one of ${cpuAlgorithms.join(", ")} or gpu:<scaler>[:<algorithm>]`, { code: "BAD_REQUEST" });
55
+ }
56
+ return { mode: "cpu", algorithm: v };
57
+ });
58
+ const videoFormat = z.enum(["mp4", "webm"]);
59
+ const imageFormat = z.enum(["jpg", "png", "webp", "avif", "gif"]);
60
+ const outputFormat = z.union([videoFormat, imageFormat]);
61
+ const compassGravity = z.enum([
62
+ "no",
63
+ "so",
64
+ "ea",
65
+ "we",
66
+ "noea",
67
+ "nowe",
68
+ "soea",
69
+ "sowe",
70
+ "ce",
71
+ ]);
72
+ const focusPointGravity = z.object({
73
+ type: z.literal("fp"),
74
+ x: z.number(),
75
+ y: z.number(),
76
+ });
77
+ const gravitySchema = z.union([compassGravity, focusPointGravity]);
78
+ const zGravity = z.string().transform((v) => {
79
+ if (v.startsWith("fp:")) {
80
+ const [, xStr, yStr] = v.split(":");
81
+ const x = parseFloat(xStr);
82
+ const y = parseFloat(yStr);
83
+ if (Number.isNaN(x) ||
84
+ Number.isNaN(y) ||
85
+ x < 0 ||
86
+ x > 1 ||
87
+ y < 0 ||
88
+ y > 1) {
89
+ throw new HTTPError("Focus point gravity requires x and y values between 0 and 1: gravity:fp:<x>:<y>", { code: "BAD_REQUEST" });
90
+ }
91
+ return { type: "fp", x, y };
92
+ }
93
+ if (v.startsWith("sm") || v.startsWith("obj")) {
94
+ throw new HTTPError(`Gravity type '${v.split(":")[0]}' is not implemented`, {
95
+ code: "NOT_IMPLEMENTED",
96
+ });
97
+ }
98
+ return compassGravity.parse(v);
99
+ });
100
+ const rgb = z.object({ r: z.number(), g: z.number(), b: z.number() });
101
+ const sides = z.object({
102
+ top: z.number(),
103
+ right: z.number(),
104
+ bottom: z.number(),
105
+ left: z.number(),
106
+ });
107
+ const resizeOptions = z.object({
108
+ /** Resize mode: fit, fill, fill-down, force, or auto. */
109
+ type: resizingType,
110
+ width: z.number(),
111
+ height: z.number(),
112
+ });
113
+ const zBool = z
114
+ .string()
115
+ .transform((v) => v === "1" || v === "t" || v === "true");
116
+ const zPositiveFloat = z.coerce.number().positive();
117
+ const zBackground = z.string().transform((v) => {
118
+ const parts = v.split(":");
119
+ if (parts.length === 1) {
120
+ const hex = parts[0].replace(/^#/, "");
121
+ if (hex.length === 3) {
122
+ return {
123
+ r: parseInt(hex[0] + hex[0], 16),
124
+ g: parseInt(hex[1] + hex[1], 16),
125
+ b: parseInt(hex[2] + hex[2], 16),
126
+ };
127
+ }
128
+ return {
129
+ r: parseInt(hex.slice(0, 2), 16),
130
+ g: parseInt(hex.slice(2, 4), 16),
131
+ b: parseInt(hex.slice(4, 6), 16),
132
+ };
133
+ }
134
+ return {
135
+ r: parseInt(parts[0], 10) || 0,
136
+ g: parseInt(parts[1], 10) || 0,
137
+ b: parseInt(parts[2], 10) || 0,
138
+ };
139
+ });
140
+ /** Schema for options that are recognised but not yet implemented. */
141
+ const notImplemented = (name) => z.string().transform(() => {
142
+ throw new HTTPError(`Option '${name}' is not implemented`, {
143
+ code: "NOT_IMPLEMENTED",
144
+ });
145
+ });
146
+ const VIDEO_FORMATS = new Set(["mp4", "webm"]);
147
+ const IMAGE_FORMATS = new Set(["jpg", "png", "webp", "avif", "gif"]);
148
+ const ALL_FORMATS = new Set([...VIDEO_FORMATS, ...IMAGE_FORMATS]);
149
+ const IMAGE_EXTENSIONS = /\.(jpe?g|png|webp|avif|gif|svg|bmp|tiff?)$/i;
150
+ export const SHORTHANDS = {
151
+ rs: "resize",
152
+ s: "size",
153
+ t: "resizing_type",
154
+ w: "width",
155
+ h: "height",
156
+ mw: "min_width",
157
+ mh: "min_height",
158
+ z: "zoom",
159
+ el: "enlarge",
160
+ ex: "extend",
161
+ exar: "extend_aspect_ratio",
162
+ c: "crop",
163
+ g: "gravity",
164
+ q: "quality",
165
+ bl: "blur",
166
+ sh: "sharpen",
167
+ rot: "rotate",
168
+ fl: "flip",
169
+ ar: "auto_rotate",
170
+ bg: "background",
171
+ bga: "background_alpha",
172
+ pd: "padding",
173
+ sm: "strip_metadata",
174
+ kcr: "keep_copyright",
175
+ scp: "strip_color_profile",
176
+ f: "format",
177
+ fr: "framerate",
178
+ ct: "cut",
179
+ tr: "trim",
180
+ ra: "resizing_algorithm",
181
+ a: "adjust",
182
+ br: "brightness",
183
+ co: "contrast",
184
+ sa: "saturation",
185
+ mc: "monochrome",
186
+ dt: "duotone",
187
+ px: "pixelate",
188
+ ush: "unsharp_masking",
189
+ bla: "blur_areas",
190
+ bd: "blur_detections",
191
+ dd: "draw_detections",
192
+ clrz: "colorize",
193
+ col: "colorize",
194
+ grd: "gradient",
195
+ gr: "gradient",
196
+ wm: "watermark",
197
+ wmu: "watermark_url",
198
+ wmt: "watermark_text",
199
+ wms: "watermark_size",
200
+ wmr: "watermark_rotate",
201
+ wmsh: "watermark_shadow",
202
+ st: "style",
203
+ eth: "enforce_thumbnail",
204
+ pg: "page",
205
+ pgs: "pages",
206
+ da: "disable_animation",
207
+ vts: "video_thumbnail_second",
208
+ vtk: "video_thumbnail_keyframes",
209
+ vtt: "video_thumbnail_tile",
210
+ vta: "video_thumbnail_animation",
211
+ fq: "format_quality",
212
+ jpgo: "jpeg_options",
213
+ pngo: "png_options",
214
+ wpo: "webp_options",
215
+ avo: "avif_options",
216
+ aq: "autoquality",
217
+ mb: "max_bytes",
218
+ op: "objects_position",
219
+ car: "crop_aspect_ratio",
220
+ skp: "skip_processing",
221
+ cb: "cache_buster",
222
+ exp: "expires",
223
+ fn: "filename",
224
+ att: "return_attachment",
225
+ pr: "preset",
226
+ fiu: "fallback_image_url",
227
+ hs: "hashsum",
228
+ mu: "mute",
229
+ msr: "max_src_resolution",
230
+ msfs: "max_src_file_size",
231
+ maf: "max_animation_frames",
232
+ mafr: "max_animation_frame_resolution",
233
+ mrd: "max_result_dimension",
234
+ };
235
+ const rawOptionsSchema = z
236
+ .object({
237
+ /** Resize with type, width, height. Format: `<type>:<w>:<h>`. */
238
+ resize: z
239
+ .string()
240
+ .transform((v) => {
241
+ const [type = "fit", w, h] = v.split(":");
242
+ return {
243
+ type: resizingType.parse(type),
244
+ width: parseInt(w, 10) || 0,
245
+ height: parseInt(h, 10) || 0,
246
+ };
247
+ })
248
+ .optional(),
249
+ /** Shorthand for width + height. Format: `<w>:<h>`. */
250
+ size: z
251
+ .string()
252
+ .transform((v) => {
253
+ const [w, h] = v.split(":");
254
+ return { width: parseInt(w, 10) || 0, height: parseInt(h, 10) || 0 };
255
+ })
256
+ .optional(),
257
+ /** Override resize type without specifying dimensions. */
258
+ resizing_type: resizingType.optional(),
259
+ width: z.coerce.number().int().optional(),
260
+ height: z.coerce.number().int().optional(),
261
+ min_width: z.coerce.number().int().optional(),
262
+ min_height: z.coerce.number().int().optional(),
263
+ zoom: z
264
+ .string()
265
+ .transform((v) => {
266
+ const parts = v.split(":");
267
+ const x = parseFloat(parts[0]) || 1;
268
+ const y = parts[1] !== undefined ? parseFloat(parts[1]) || 1 : x;
269
+ return { x, y };
270
+ })
271
+ .optional(),
272
+ /** Device pixel ratio — multiplies dimensions and padding. */
273
+ dpr: z.coerce.number().positive().optional(),
274
+ /** Allow upscaling smaller images. */
275
+ enlarge: zBool.optional(),
276
+ /** Pad undersized images to fill target dimensions. Format: `<enabled>[:<gravity>]`. */
277
+ extend: z
278
+ .string()
279
+ .transform((v) => {
280
+ const parts = v.split(":");
281
+ const enabled = parts[0] === "1" || parts[0] === "t" || parts[0] === "true";
282
+ return {
283
+ enabled,
284
+ gravity: compassGravity.safeParse(parts[1]).data ?? "ce",
285
+ };
286
+ })
287
+ .optional(),
288
+ extend_aspect_ratio: z
289
+ .string()
290
+ .transform((v) => {
291
+ const parts = v.split(":");
292
+ const enabled = parts[0] === "1" || parts[0] === "t" || parts[0] === "true";
293
+ return {
294
+ enabled,
295
+ gravity: compassGravity.safeParse(parts[1]).data ?? "ce",
296
+ };
297
+ })
298
+ .optional(),
299
+ crop: z
300
+ .string()
301
+ .transform((v) => {
302
+ const [w, h, ...rest] = v.split(":");
303
+ const gStr = rest.join(":");
304
+ let cropGravity;
305
+ if (gStr) {
306
+ cropGravity = zGravity.parse(gStr);
307
+ }
308
+ return {
309
+ width: parseFloat(w) || 0,
310
+ height: parseFloat(h) || 0,
311
+ gravity: cropGravity,
312
+ };
313
+ })
314
+ .optional(),
315
+ gravity: zGravity.optional(),
316
+ quality: z.coerce.number().int().optional(),
317
+ blur: z.coerce.number().optional(),
318
+ sharpen: z.coerce.number().optional(),
319
+ rotate: z.coerce.number().int().optional(),
320
+ flip: z
321
+ .string()
322
+ .transform((v) => {
323
+ const [h, vert] = v.split(":");
324
+ return {
325
+ horizontal: h === "1" || h === "t" || h === "true",
326
+ vertical: vert === "1" || vert === "t" || vert === "true",
327
+ };
328
+ })
329
+ .optional(),
330
+ auto_rotate: zBool.optional(),
331
+ background: zBackground.optional(),
332
+ /** Background opacity (0–1). */
333
+ background_alpha: z.coerce.number().min(0).max(1).optional(),
334
+ /** Canvas padding. Format: `<top>[:<right>[:<bottom>[:<left>]]]`. */
335
+ padding: z
336
+ .string()
337
+ .transform((v) => {
338
+ const parts = v.split(":").map((p) => parseInt(p, 10) || 0);
339
+ const top = parts[0];
340
+ const right = parts[1] ?? top;
341
+ const bottom = parts[2] ?? top;
342
+ const left = parts[3] ?? right;
343
+ return { top, right, bottom, left };
344
+ })
345
+ .optional(),
346
+ strip_metadata: zBool.optional(),
347
+ /** Preserve copyright metadata when stripping. */
348
+ keep_copyright: zBool.optional(),
349
+ /** Convert ICC colour profile to sRGB and remove it. */
350
+ strip_color_profile: zBool.optional(),
351
+ format: z
352
+ .string()
353
+ .transform((v) => (v === "jpeg" ? "jpg" : v))
354
+ .pipe(z.string().refine((v) => v === "best" || ALL_FORMATS.has(v)))
355
+ .optional(),
356
+ /** Output framerate in fps (video only). */
357
+ framerate: zPositiveFloat.optional(),
358
+ /** Limit video duration in seconds (video only). */
359
+ cut: zPositiveFloat.optional(),
360
+ /** Strip audio from video output. */
361
+ mute: zBool.optional(),
362
+ /** Remove uniform borders. Format: `<threshold>[:<colour>[:<equal_hor>[:<equal_vert>]]]`. */
363
+ trim: z
364
+ .string()
365
+ .transform((v) => {
366
+ const [threshold, colour, equalHor, equalVert] = v.split(":");
367
+ return {
368
+ threshold: parseFloat(threshold) || 0,
369
+ colour: colour || undefined,
370
+ equalHor: equalHor === "1" || equalHor === "t" || equalHor === "true",
371
+ equalVert: equalVert === "1" || equalVert === "t" || equalVert === "true",
372
+ };
373
+ })
374
+ .optional(),
375
+ /** Pixelate with given block size in pixels. */
376
+ pixelate: z.coerce.number().int().positive().optional(),
377
+ resizing_algorithm: zResizingAlgorithm.optional(),
378
+ /** Meta-option: `<brightness>:<contrast>:<saturation>`. */
379
+ adjust: z
380
+ .string()
381
+ .transform((v) => {
382
+ const [b, c, s] = v.split(":");
383
+ return {
384
+ brightness: b ? parseInt(b, 10) : 0,
385
+ contrast: c ? parseFloat(c) : 1,
386
+ saturation: s ? parseFloat(s) : 1,
387
+ };
388
+ })
389
+ .optional(),
390
+ /** Brightness (-255 to 255). */
391
+ brightness: z.coerce.number().int().min(-255).max(255).optional(),
392
+ /** Contrast multiplier (1 = unchanged). */
393
+ contrast: z.coerce.number().positive().optional(),
394
+ /** Saturation multiplier (1 = unchanged). */
395
+ saturation: z.coerce.number().positive().optional(),
396
+ /** Monochrome effect. Format: `<intensity>[:<hex_colour>]`. */
397
+ monochrome: z
398
+ .string()
399
+ .transform((v) => {
400
+ const [intensity, colour] = v.split(":");
401
+ return {
402
+ intensity: parseFloat(intensity) || 0,
403
+ colour: colour || "b3b3b3",
404
+ };
405
+ })
406
+ .optional(),
407
+ /** Duotone effect. Format: `<intensity>[:<shadow_colour>[:<highlight_colour>]]`. */
408
+ duotone: z
409
+ .string()
410
+ .transform((v) => {
411
+ const [intensity, c1, c2] = v.split(":");
412
+ return {
413
+ intensity: parseFloat(intensity) || 0,
414
+ colour1: c1 || "000000",
415
+ colour2: c2 || "ffffff",
416
+ };
417
+ })
418
+ .optional(),
419
+ /** Unsharp masking. Format: `<mode>:<weight>:<divider>`. */
420
+ unsharp_masking: z
421
+ .string()
422
+ .transform((v) => {
423
+ const [mode, weight, divider] = v.split(":");
424
+ return {
425
+ mode: mode || "auto",
426
+ weight: weight ? parseFloat(weight) : 1,
427
+ divider: divider ? parseFloat(divider) : 24,
428
+ };
429
+ })
430
+ .optional(),
431
+ blur_areas: notImplemented("blur_areas").optional(),
432
+ blur_detections: notImplemented("blur_detections").optional(),
433
+ draw_detections: notImplemented("draw_detections").optional(),
434
+ /** Colour overlay. Format: `<opacity>[:<hex_colour>[:<keep_alpha>]]`. */
435
+ colorize: z
436
+ .string()
437
+ .transform((v) => {
438
+ const [opacity, colour, keepAlpha] = v.split(":");
439
+ return {
440
+ opacity: parseFloat(opacity) || 0,
441
+ colour: colour || "000",
442
+ keepAlpha: keepAlpha === "1" || keepAlpha === "t" || keepAlpha === "true",
443
+ };
444
+ })
445
+ .optional(),
446
+ /** Gradient overlay. Format: `<opacity>[:<colour>[:<direction>[:<start>[:<stop>]]]]`. */
447
+ gradient: z
448
+ .string()
449
+ .transform((v) => {
450
+ const [opacity, colour, direction, start, stop] = v.split(":");
451
+ return {
452
+ opacity: parseFloat(opacity) || 0,
453
+ colour: colour || "000",
454
+ direction: direction || "down",
455
+ start: start ? parseFloat(start) : 0,
456
+ stop: stop ? parseFloat(stop) : 1,
457
+ };
458
+ })
459
+ .optional(),
460
+ /** Watermark overlay. Format: `<opacity>[:<position>[:<x_offset>[:<y_offset>[:<scale>]]]]`. */
461
+ watermark: notImplemented("watermark").optional(),
462
+ /** Custom watermark image URL (base64-encoded). */
463
+ watermark_url: notImplemented("watermark_url").optional(),
464
+ /** Watermark text (base64-encoded, supports Pango markup). */
465
+ watermark_text: notImplemented("watermark_text").optional(),
466
+ /** Watermark dimensions. Format: `<width>:<height>`. */
467
+ watermark_size: notImplemented("watermark_size").optional(),
468
+ /** Watermark rotation in degrees. */
469
+ watermark_rotate: notImplemented("watermark_rotate").optional(),
470
+ /** Watermark shadow blur sigma. */
471
+ watermark_shadow: notImplemented("watermark_shadow").optional(),
472
+ /** Text style (Pango markup). */
473
+ style: notImplemented("style").optional(),
474
+ /** Set output DPI metadata. */
475
+ dpi: z.coerce.number().positive().optional(),
476
+ /** Prefer embedded thumbnail over full image (HEIC/AVIF). */
477
+ enforce_thumbnail: zBool.optional(),
478
+ /** Per-format quality. Format: `<fmt1>:<q1>:<fmt2>:<q2>:...`. */
479
+ format_quality: z
480
+ .string()
481
+ .transform((v) => {
482
+ const parts = v.split(":");
483
+ const result = {};
484
+ for (let i = 0; i < parts.length - 1; i += 2) {
485
+ const fmt = parts[i] === "jpeg" ? "jpg" : parts[i];
486
+ result[fmt] = parseInt(parts[i + 1], 10);
487
+ }
488
+ return result;
489
+ })
490
+ .optional(),
491
+ /** Autoquality. Format: `<method>:<target>:<min>:<max>:<allowed_error>`. Methods: dssim, size. */
492
+ autoquality: z
493
+ .string()
494
+ .transform((v) => {
495
+ const [method, target, min, max, err] = v.split(":");
496
+ const m = method || "dssim";
497
+ if (m !== "dssim" && m !== "size") {
498
+ throw new HTTPError(`Autoquality method '${m}' is not implemented — supported: dssim, size`, { code: "NOT_IMPLEMENTED" });
499
+ }
500
+ return {
501
+ method: m,
502
+ target: target ? parseFloat(target) : m === "size" ? 0 : 0.02,
503
+ min: min ? parseInt(min, 10) : 70,
504
+ max: max ? parseInt(max, 10) : 80,
505
+ allowedError: err ? parseFloat(err) : 0.001,
506
+ };
507
+ })
508
+ .optional(),
509
+ /** Max output size in bytes — degrades quality until under limit. */
510
+ max_bytes: z.coerce.number().int().positive().optional(),
511
+ /** JPEG options. Format: `<progressive>:<no_subsample>:<trellis_quant>:<overshoot_deringing>:<optimize_scans>:<quant_table>`. */
512
+ jpeg_options: z
513
+ .string()
514
+ .transform((v) => {
515
+ const [progressive, noSubsample, trellisQuant, overshootDeringing, optimizeScans, quantTable,] = v.split(":");
516
+ return {
517
+ progressive: progressive === "1" ||
518
+ progressive === "t" ||
519
+ progressive === "true",
520
+ noSubsample: noSubsample === "1" ||
521
+ noSubsample === "t" ||
522
+ noSubsample === "true",
523
+ trellisQuant: trellisQuant === "1" ||
524
+ trellisQuant === "t" ||
525
+ trellisQuant === "true",
526
+ overshootDeringing: overshootDeringing === "1" ||
527
+ overshootDeringing === "t" ||
528
+ overshootDeringing === "true",
529
+ optimizeScans: optimizeScans === "1" ||
530
+ optimizeScans === "t" ||
531
+ optimizeScans === "true",
532
+ quantTable: quantTable ? parseInt(quantTable, 10) : undefined,
533
+ };
534
+ })
535
+ .optional(),
536
+ /** PNG options. Format: `<interlaced>:<quantize>:<quantization_colours>`. */
537
+ png_options: z
538
+ .string()
539
+ .transform((v) => {
540
+ const [interlaced, quantize, colours] = v.split(":");
541
+ return {
542
+ interlaced: interlaced === "1" || interlaced === "t" || interlaced === "true",
543
+ quantize: quantize === "1" || quantize === "t" || quantize === "true",
544
+ quantizationColours: colours ? parseInt(colours, 10) : undefined,
545
+ };
546
+ })
547
+ .optional(),
548
+ /** WebP options. Format: `<compression>:<smart_subsample>:<preset>`. */
549
+ webp_options: z
550
+ .string()
551
+ .transform((v) => {
552
+ const [compression, smartSubsample, preset] = v.split(":");
553
+ return {
554
+ compression: compression ? parseInt(compression, 10) : undefined,
555
+ smartSubsample: smartSubsample === "1" ||
556
+ smartSubsample === "t" ||
557
+ smartSubsample === "true",
558
+ preset: preset || undefined,
559
+ };
560
+ })
561
+ .optional(),
562
+ /** AVIF options. Format: `<subsample>`. */
563
+ avif_options: z
564
+ .string()
565
+ .transform((v) => {
566
+ return { subsample: v || undefined };
567
+ })
568
+ .optional(),
569
+ page: notImplemented("page").optional(),
570
+ pages: notImplemented("pages").optional(),
571
+ disable_animation: notImplemented("disable_animation").optional(),
572
+ /** Extract video frame at given second. */
573
+ video_thumbnail_second: z.coerce.number().optional(),
574
+ /** Use only keyframes for video thumbnails. */
575
+ video_thumbnail_keyframes: zBool.optional(),
576
+ video_thumbnail_tile: notImplemented("video_thumbnail_tile").optional(),
577
+ /** Video animation. Format: `<step>:<delay>:<frames>:<frame_width>:<frame_height>:<extend_frame>:<trim>:<fill>:<focus_x>:<focus_y>`. */
578
+ video_thumbnail_animation: z
579
+ .string()
580
+ .transform((v) => {
581
+ const [step, delay, frames, frameWidth, frameHeight, extendFrame, trim, fill, focusX, focusY,] = v.split(":");
582
+ return {
583
+ step: step ? parseFloat(step) : 0,
584
+ delay: delay ? parseInt(delay, 10) : 100,
585
+ frames: frames ? parseInt(frames, 10) : 0,
586
+ frameWidth: frameWidth ? parseInt(frameWidth, 10) : 0,
587
+ frameHeight: frameHeight ? parseInt(frameHeight, 10) : 0,
588
+ extendFrame: extendFrame === "1",
589
+ trim: trim === "1",
590
+ fill: fill === "1",
591
+ focusX: focusX ? parseFloat(focusX) : 0.5,
592
+ focusY: focusY ? parseFloat(focusY) : 0.5,
593
+ };
594
+ })
595
+ .optional(),
596
+ /** Skip processing for listed extensions. Format: `<ext1>:<ext2>:...`. */
597
+ skip_processing: z
598
+ .string()
599
+ .transform((v) => {
600
+ if (!v)
601
+ return [];
602
+ return v.split(":").map((e) => (e === "jpeg" ? "jpg" : e));
603
+ })
604
+ .optional(),
605
+ /** Return source without any processing. */
606
+ raw: zBool.optional(),
607
+ /** Ignored value used to differentiate CDN cache keys. */
608
+ cache_buster: z.string().optional(),
609
+ /** Unix timestamp after which the URL returns 404. */
610
+ expires: z.coerce.number().int().optional(),
611
+ /** Override the download filename in Content-Disposition. */
612
+ filename: z.string().optional(),
613
+ /** When true, set Content-Disposition: attachment. */
614
+ return_attachment: zBool.optional(),
615
+ preset: notImplemented("preset").optional(),
616
+ /** Fallback image URL (base64url-encoded) served when the source fails to load. */
617
+ fallback_image_url: z.string().optional(),
618
+ /** Expected hex-encoded checksum of the source image. Format: `<type>:<hex_digest>`. */
619
+ hashsum: z
620
+ .string()
621
+ .transform((v) => {
622
+ const idx = v.indexOf(":");
623
+ if (idx === -1) {
624
+ throw new HTTPError("hashsum requires format <type>:<hex_digest>", {
625
+ code: "BAD_REQUEST",
626
+ });
627
+ }
628
+ const type = v.slice(0, idx);
629
+ const hash = v.slice(idx + 1);
630
+ return { type, hash };
631
+ })
632
+ .optional(),
633
+ /** Max source resolution in megapixels. */
634
+ max_src_resolution: z.coerce.number().positive().optional(),
635
+ /** Max source file size in bytes. */
636
+ max_src_file_size: z.coerce.number().int().positive().optional(),
637
+ /** Max animation frames. */
638
+ max_animation_frames: z.coerce.number().int().positive().optional(),
639
+ /** Max animation frame resolution in megapixels. */
640
+ max_animation_frame_resolution: z.coerce.number().positive().optional(),
641
+ /** Max result width or height in pixels. */
642
+ max_result_dimension: z.coerce.number().int().positive().optional(),
643
+ objects_position: notImplemented("objects_position").optional(),
644
+ crop_aspect_ratio: z
645
+ .string()
646
+ .transform((v) => {
647
+ const [w, h] = v.split(":");
648
+ const width = parseFloat(w);
649
+ const height = parseFloat(h);
650
+ if (!width || !height || width <= 0 || height <= 0) {
651
+ throw new HTTPError("crop_aspect_ratio requires two positive numbers: car:<width>:<height>", { code: "BAD_REQUEST" });
652
+ }
653
+ return width / height;
654
+ })
655
+ .optional(),
656
+ })
657
+ .passthrough();
658
+ const optionsSchema = rawOptionsSchema.transform((data) => {
659
+ let resizeType = data.resize?.type ?? data.resizing_type ?? "fit";
660
+ let resize = data.resize;
661
+ const w = data.size?.width ?? data.width;
662
+ const h = data.size?.height ?? data.height;
663
+ // Apply standalone resizing_type
664
+ if (data.resizing_type && resize) {
665
+ resize = { ...resize, type: data.resizing_type };
666
+ resizeType = data.resizing_type;
667
+ }
668
+ if (!resize && (w || h)) {
669
+ resize = { type: resizeType, width: w ?? 0, height: h ?? 0 };
670
+ }
671
+ else if (resize) {
672
+ if (w)
673
+ resize.width = w;
674
+ if (h)
675
+ resize.height = h;
676
+ }
677
+ // Apply zoom multiplier to dimensions
678
+ if (data.zoom && resize) {
679
+ resize = {
680
+ ...resize,
681
+ width: resize.width ? Math.round(resize.width * data.zoom.x) : 0,
682
+ height: resize.height ? Math.round(resize.height * data.zoom.y) : 0,
683
+ };
684
+ }
685
+ // Apply dpr multiplier to dimensions and padding
686
+ let padding = data.padding;
687
+ if (data.dpr && data.dpr !== 1) {
688
+ const d = data.dpr;
689
+ if (resize) {
690
+ resize = {
691
+ ...resize,
692
+ width: resize.width ? Math.round(resize.width * d) : 0,
693
+ height: resize.height ? Math.round(resize.height * d) : 0,
694
+ };
695
+ }
696
+ if (padding) {
697
+ padding = {
698
+ top: Math.round(padding.top * d),
699
+ right: Math.round(padding.right * d),
700
+ bottom: Math.round(padding.bottom * d),
701
+ left: Math.round(padding.left * d),
702
+ };
703
+ }
704
+ }
705
+ return {
706
+ resize,
707
+ resizingAlgorithm: data.resizing_algorithm,
708
+ minWidth: data.min_width,
709
+ minHeight: data.min_height,
710
+ extend: data.extend,
711
+ extendAspectRatio: data.extend_aspect_ratio,
712
+ framerate: data.framerate,
713
+ cut: data.cut,
714
+ mute: data.mute,
715
+ trim: data.trim,
716
+ brightness: data.brightness ?? data.adjust?.brightness ?? 0,
717
+ contrast: data.contrast ?? data.adjust?.contrast ?? 1,
718
+ saturation: data.saturation ?? data.adjust?.saturation ?? 1,
719
+ monochrome: data.monochrome,
720
+ duotone: data.duotone,
721
+ quality: data.quality,
722
+ formatQuality: data.format_quality,
723
+ autoquality: data.autoquality,
724
+ maxBytes: data.max_bytes,
725
+ jpegOptions: data.jpeg_options,
726
+ pngOptions: data.png_options,
727
+ webpOptions: data.webp_options,
728
+ avifOptions: data.avif_options,
729
+ blur: data.blur,
730
+ sharpen: data.sharpen,
731
+ pixelate: data.pixelate,
732
+ unsharpMasking: data.unsharp_masking,
733
+ colorize: data.colorize,
734
+ gradient: data.gradient,
735
+ rotate: data.rotate,
736
+ flip: data.flip,
737
+ autoRotate: data.auto_rotate,
738
+ background: data.background,
739
+ backgroundAlpha: data.background_alpha,
740
+ padding,
741
+ stripMetadata: data.strip_metadata,
742
+ dpi: data.dpi,
743
+ enforceThumbnail: data.enforce_thumbnail,
744
+ videoThumbnailSecond: data.video_thumbnail_second,
745
+ videoThumbnailKeyframes: data.video_thumbnail_keyframes,
746
+ videoThumbnailAnimation: data.video_thumbnail_animation,
747
+ keepCopyright: data.keep_copyright,
748
+ stripColorProfile: data.strip_color_profile,
749
+ crop: data.crop,
750
+ cropAspectRatio: data.crop_aspect_ratio,
751
+ gravity: data.gravity,
752
+ skipProcessing: data.skip_processing,
753
+ raw: data.raw,
754
+ cacheBuster: data.cache_buster,
755
+ expires: data.expires,
756
+ filename: data.filename,
757
+ returnAttachment: data.return_attachment,
758
+ fallbackImageUrl: data.fallback_image_url,
759
+ hashsum: data.hashsum,
760
+ maxSrcResolution: data.max_src_resolution,
761
+ maxSrcFileSize: data.max_src_file_size,
762
+ maxAnimationFrames: data.max_animation_frames,
763
+ maxAnimationFrameResolution: data.max_animation_frame_resolution,
764
+ maxResultDimension: data.max_result_dimension,
765
+ enlarge: data.enlarge,
766
+ bestFormat: data.format === "best" ? true : undefined,
767
+ formatOverride: data.format && data.format !== "best"
768
+ ? data.format
769
+ : undefined,
770
+ };
771
+ });
772
+ /** Zod schema for runtime validation of parsed asset-proxy URL options. The `ParsedUrlInput` interface is the authoritative type definition; this schema validates against it at compile time via `satisfies`. */
773
+ export const parsedUrlSchema = z.object({
774
+ resize: resizeOptions.optional(),
775
+ resizingAlgorithm: resizingAlgorithmSchema.optional(),
776
+ sourceUrl: z.string(),
777
+ outputFormat,
778
+ minWidth: z.number().optional(),
779
+ minHeight: z.number().optional(),
780
+ extend: z
781
+ .object({ enabled: z.boolean(), gravity: compassGravity })
782
+ .optional(),
783
+ extendAspectRatio: z
784
+ .object({ enabled: z.boolean(), gravity: compassGravity })
785
+ .optional(),
786
+ framerate: z.number().optional(),
787
+ cut: z.number().optional(),
788
+ mute: z.boolean().optional(),
789
+ trim: z
790
+ .object({
791
+ threshold: z.number(),
792
+ colour: z.string().optional(),
793
+ equalHor: z.boolean(),
794
+ equalVert: z.boolean(),
795
+ })
796
+ .optional(),
797
+ brightness: z.number(),
798
+ contrast: z.number(),
799
+ saturation: z.number(),
800
+ monochrome: z
801
+ .object({ intensity: z.number(), colour: z.string() })
802
+ .optional(),
803
+ duotone: z
804
+ .object({
805
+ intensity: z.number(),
806
+ colour1: z.string(),
807
+ colour2: z.string(),
808
+ })
809
+ .optional(),
810
+ quality: z.number().optional(),
811
+ formatQuality: z.record(z.string(), z.number()).optional(),
812
+ autoquality: z
813
+ .object({
814
+ method: z.enum(["dssim", "size"]),
815
+ target: z.number(),
816
+ min: z.number(),
817
+ max: z.number(),
818
+ allowedError: z.number(),
819
+ })
820
+ .optional(),
821
+ maxBytes: z.number().optional(),
822
+ jpegOptions: z
823
+ .object({
824
+ progressive: z.boolean(),
825
+ noSubsample: z.boolean(),
826
+ trellisQuant: z.boolean(),
827
+ overshootDeringing: z.boolean(),
828
+ optimizeScans: z.boolean(),
829
+ quantTable: z.number().optional(),
830
+ })
831
+ .optional(),
832
+ pngOptions: z
833
+ .object({
834
+ interlaced: z.boolean(),
835
+ quantize: z.boolean(),
836
+ quantizationColours: z.number().optional(),
837
+ })
838
+ .optional(),
839
+ webpOptions: z
840
+ .object({
841
+ compression: z.number().optional(),
842
+ smartSubsample: z.boolean(),
843
+ preset: z.string().optional(),
844
+ })
845
+ .optional(),
846
+ avifOptions: z
847
+ .object({
848
+ subsample: z.string().optional(),
849
+ })
850
+ .optional(),
851
+ blur: z.number().optional(),
852
+ sharpen: z.number().optional(),
853
+ pixelate: z.number().optional(),
854
+ unsharpMasking: z
855
+ .object({ mode: z.string(), weight: z.number(), divider: z.number() })
856
+ .optional(),
857
+ colorize: z
858
+ .object({
859
+ opacity: z.number(),
860
+ colour: z.string(),
861
+ keepAlpha: z.boolean(),
862
+ })
863
+ .optional(),
864
+ gradient: z
865
+ .object({
866
+ opacity: z.number(),
867
+ colour: z.string(),
868
+ direction: z.string(),
869
+ start: z.number(),
870
+ stop: z.number(),
871
+ })
872
+ .optional(),
873
+ rotate: z.number().optional(),
874
+ flip: z.object({ horizontal: z.boolean(), vertical: z.boolean() }).optional(),
875
+ autoRotate: z.boolean().optional(),
876
+ background: rgb.optional(),
877
+ backgroundAlpha: z.number().optional(),
878
+ padding: sides.optional(),
879
+ stripMetadata: z.boolean().optional(),
880
+ keepCopyright: z.boolean().optional(),
881
+ stripColorProfile: z.boolean().optional(),
882
+ dpi: z.number().optional(),
883
+ enforceThumbnail: z.boolean().optional(),
884
+ videoThumbnailSecond: z.number().optional(),
885
+ videoThumbnailKeyframes: z.boolean().optional(),
886
+ videoThumbnailAnimation: z
887
+ .object({
888
+ step: z.number(),
889
+ delay: z.number(),
890
+ frames: z.number(),
891
+ frameWidth: z.number(),
892
+ frameHeight: z.number(),
893
+ extendFrame: z.boolean(),
894
+ trim: z.boolean(),
895
+ fill: z.boolean(),
896
+ focusX: z.number(),
897
+ focusY: z.number(),
898
+ })
899
+ .optional(),
900
+ crop: z
901
+ .object({
902
+ width: z.number(),
903
+ height: z.number(),
904
+ gravity: gravitySchema.optional(),
905
+ })
906
+ .optional(),
907
+ cropAspectRatio: z.number().optional(),
908
+ gravity: gravitySchema.optional(),
909
+ enlarge: z.boolean().optional(),
910
+ bestFormat: z.boolean().optional(),
911
+ skipProcessing: z.array(z.string()).optional(),
912
+ raw: z.boolean().optional(),
913
+ cacheBuster: z.string().optional(),
914
+ expires: z.number().optional(),
915
+ filename: z.string().optional(),
916
+ returnAttachment: z.boolean().optional(),
917
+ fallbackImageUrl: z.string().optional(),
918
+ hashsum: z.object({ type: z.string(), hash: z.string() }).optional(),
919
+ maxSrcResolution: z.number().optional(),
920
+ maxSrcFileSize: z.number().optional(),
921
+ maxAnimationFrames: z.number().optional(),
922
+ maxAnimationFrameResolution: z.number().optional(),
923
+ maxResultDimension: z.number().optional(),
924
+ });
925
+ export function isImageUrl(parsed) {
926
+ if (IMAGE_FORMATS.has(parsed.outputFormat))
927
+ return true;
928
+ if (VIDEO_FORMATS.has(parsed.outputFormat))
929
+ return false;
930
+ // Video thumbnail options produce an image even from a video source
931
+ if (parsed.videoThumbnailSecond !== undefined ||
932
+ parsed.videoThumbnailAnimation !== undefined)
933
+ return true;
934
+ if (parsed.framerate !== undefined || parsed.cut !== undefined)
935
+ return false;
936
+ if (IMAGE_EXTENSIONS.test(parsed.sourceUrl))
937
+ return true;
938
+ return false;
939
+ }
940
+ export function isVideoUrl(parsed) {
941
+ return !isImageUrl(parsed);
942
+ }
943
+ /** Parses an imgproxy-format processing path (after signature has been stripped). Supports `/<options>/plain/<source_url>[@<format>]` and `/<options>/enc/<encrypted_source_url>[@<format>]`. */
944
+ export function parseProcessingUrl(path, options) {
945
+ const withoutPrefix = path.replace(/^\//, "");
946
+ const plainIdx = withoutPrefix.indexOf("/plain/");
947
+ const encIdx = withoutPrefix.indexOf("/enc/");
948
+ let optionsPart;
949
+ let sourceUrl;
950
+ let encrypted = false;
951
+ if (plainIdx !== -1) {
952
+ optionsPart = withoutPrefix.slice(0, plainIdx);
953
+ sourceUrl = withoutPrefix.slice(plainIdx + "/plain/".length);
954
+ }
955
+ else if (encIdx !== -1) {
956
+ optionsPart = withoutPrefix.slice(0, encIdx);
957
+ sourceUrl = withoutPrefix.slice(encIdx + "/enc/".length);
958
+ encrypted = true;
959
+ }
960
+ else {
961
+ throw new Error("Unsupported URL format: expected /plain/ or /enc/ source URL");
962
+ }
963
+ if (!sourceUrl) {
964
+ throw new Error("Missing source URL");
965
+ }
966
+ // Parse @format suffix from source URL
967
+ let format = "mp4";
968
+ let hasFormatSuffix = false;
969
+ let bestFormatSuffix = false;
970
+ const formatMatch = sourceUrl.match(/@([a-z0-9]+)$/);
971
+ if (formatMatch) {
972
+ let fmt = formatMatch[1];
973
+ if (fmt === "jpeg")
974
+ fmt = "jpg";
975
+ if (fmt === "best") {
976
+ bestFormatSuffix = true;
977
+ sourceUrl = sourceUrl.slice(0, -formatMatch[0].length);
978
+ }
979
+ else if (ALL_FORMATS.has(fmt)) {
980
+ format = fmt;
981
+ sourceUrl = sourceUrl.slice(0, -formatMatch[0].length);
982
+ hasFormatSuffix = true;
983
+ }
984
+ }
985
+ if (encrypted) {
986
+ if (!options?.encryptionKey) {
987
+ throw new HTTPError("Encrypted source URLs are not supported: no encryption key provided", { code: "BAD_REQUEST" });
988
+ }
989
+ sourceUrl = decryptSourceUrl(sourceUrl, options.encryptionKey);
990
+ }
991
+ // Parse option segments into a { name: value } record, then validate with Zod
992
+ const raw = Object.fromEntries(optionsPart
993
+ .split("/")
994
+ .filter(Boolean)
995
+ .map((segment) => {
996
+ const idx = segment.indexOf(":");
997
+ if (idx === -1)
998
+ return [segment, ""];
999
+ const name = segment.slice(0, idx);
1000
+ const value = segment.slice(idx + 1);
1001
+ return [SHORTHANDS[name] ?? name, value];
1002
+ }));
1003
+ const parsedOptions = optionsSchema.parse(raw);
1004
+ if (parsedOptions.formatOverride) {
1005
+ format = parsedOptions.formatOverride;
1006
+ }
1007
+ if (!hasFormatSuffix && !parsedOptions.formatOverride) {
1008
+ if (IMAGE_EXTENSIONS.test(sourceUrl)) {
1009
+ format = "jpg";
1010
+ }
1011
+ // Video thumbnail options produce an image — default to jpg if no explicit image format
1012
+ if ((parsedOptions.videoThumbnailSecond !== undefined ||
1013
+ parsedOptions.videoThumbnailAnimation !== undefined) &&
1014
+ VIDEO_FORMATS.has(format)) {
1015
+ format = "jpg";
1016
+ }
1017
+ }
1018
+ const parsed = parsedUrlSchema.parse({
1019
+ resize: parsedOptions.resize,
1020
+ resizingAlgorithm: parsedOptions.resizingAlgorithm,
1021
+ sourceUrl,
1022
+ outputFormat: format,
1023
+ minWidth: parsedOptions.minWidth,
1024
+ minHeight: parsedOptions.minHeight,
1025
+ extend: parsedOptions.extend,
1026
+ extendAspectRatio: parsedOptions.extendAspectRatio,
1027
+ framerate: parsedOptions.framerate,
1028
+ cut: parsedOptions.cut,
1029
+ mute: parsedOptions.mute,
1030
+ trim: parsedOptions.trim,
1031
+ brightness: parsedOptions.brightness,
1032
+ contrast: parsedOptions.contrast,
1033
+ saturation: parsedOptions.saturation,
1034
+ monochrome: parsedOptions.monochrome,
1035
+ duotone: parsedOptions.duotone,
1036
+ quality: parsedOptions.quality,
1037
+ formatQuality: parsedOptions.formatQuality,
1038
+ autoquality: parsedOptions.autoquality,
1039
+ maxBytes: parsedOptions.maxBytes,
1040
+ jpegOptions: parsedOptions.jpegOptions,
1041
+ pngOptions: parsedOptions.pngOptions,
1042
+ webpOptions: parsedOptions.webpOptions,
1043
+ avifOptions: parsedOptions.avifOptions,
1044
+ blur: parsedOptions.blur,
1045
+ sharpen: parsedOptions.sharpen,
1046
+ pixelate: parsedOptions.pixelate,
1047
+ unsharpMasking: parsedOptions.unsharpMasking,
1048
+ colorize: parsedOptions.colorize,
1049
+ gradient: parsedOptions.gradient,
1050
+ rotate: parsedOptions.rotate,
1051
+ flip: parsedOptions.flip,
1052
+ autoRotate: parsedOptions.autoRotate,
1053
+ background: parsedOptions.background,
1054
+ backgroundAlpha: parsedOptions.backgroundAlpha,
1055
+ padding: parsedOptions.padding,
1056
+ stripMetadata: parsedOptions.stripMetadata,
1057
+ keepCopyright: parsedOptions.keepCopyright,
1058
+ stripColorProfile: parsedOptions.stripColorProfile,
1059
+ dpi: parsedOptions.dpi,
1060
+ enforceThumbnail: parsedOptions.enforceThumbnail,
1061
+ videoThumbnailSecond: parsedOptions.videoThumbnailSecond,
1062
+ videoThumbnailKeyframes: parsedOptions.videoThumbnailKeyframes,
1063
+ videoThumbnailAnimation: parsedOptions.videoThumbnailAnimation,
1064
+ crop: parsedOptions.crop,
1065
+ cropAspectRatio: parsedOptions.cropAspectRatio,
1066
+ gravity: parsedOptions.gravity,
1067
+ enlarge: parsedOptions.enlarge,
1068
+ bestFormat: parsedOptions.bestFormat || bestFormatSuffix || undefined,
1069
+ skipProcessing: parsedOptions.skipProcessing,
1070
+ raw: parsedOptions.raw,
1071
+ cacheBuster: parsedOptions.cacheBuster,
1072
+ expires: parsedOptions.expires,
1073
+ filename: parsedOptions.filename,
1074
+ returnAttachment: parsedOptions.returnAttachment,
1075
+ fallbackImageUrl: parsedOptions.fallbackImageUrl,
1076
+ hashsum: parsedOptions.hashsum,
1077
+ maxSrcResolution: parsedOptions.maxSrcResolution,
1078
+ maxSrcFileSize: parsedOptions.maxSrcFileSize,
1079
+ maxAnimationFrames: parsedOptions.maxAnimationFrames,
1080
+ maxAnimationFrameResolution: parsedOptions.maxAnimationFrameResolution,
1081
+ maxResultDimension: parsedOptions.maxResultDimension,
1082
+ });
1083
+ return parsed;
1084
+ }
1085
+ //# sourceMappingURL=parse.js.map