img-upload-kit 1.1.1 → 2.0.1

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.
Files changed (111) hide show
  1. package/CHANGELOG.md +89 -74
  2. package/MIGRATION.md +192 -0
  3. package/README.md +243 -259
  4. package/dist/bin.js +1186 -0
  5. package/dist/bin.js.map +1 -0
  6. package/dist/blur.cjs +40 -412
  7. package/dist/blur.cjs.map +1 -1
  8. package/dist/blur.d.cts +5 -5
  9. package/dist/blur.d.ts +5 -5
  10. package/dist/blur.js +39 -393
  11. package/dist/blur.js.map +1 -1
  12. package/dist/blurDataUtils-DveBRtqE.d.cts +43 -0
  13. package/dist/blurDataUtils-yx9Q6HlU.d.ts +43 -0
  14. package/dist/blurService-D5TpMNji.d.ts +83 -0
  15. package/dist/blurService-DacwylJx.d.cts +83 -0
  16. package/dist/chunk-25GYKQ6J.js +158 -0
  17. package/dist/chunk-25GYKQ6J.js.map +1 -0
  18. package/dist/chunk-34L4TEE3.cjs +226 -0
  19. package/dist/chunk-34L4TEE3.cjs.map +1 -0
  20. package/dist/chunk-DFCSN3X7.cjs +158 -0
  21. package/dist/chunk-DFCSN3X7.cjs.map +1 -0
  22. package/dist/chunk-EWVIL57J.js +294 -0
  23. package/dist/chunk-EWVIL57J.js.map +1 -0
  24. package/dist/chunk-FP5YIWG5.cjs +135 -0
  25. package/dist/chunk-FP5YIWG5.cjs.map +1 -0
  26. package/dist/chunk-HILELQGS.js +226 -0
  27. package/dist/chunk-HILELQGS.js.map +1 -0
  28. package/dist/chunk-IDLLCVGB.js +2370 -0
  29. package/dist/chunk-IDLLCVGB.js.map +1 -0
  30. package/dist/chunk-J6A44Y3W.js +514 -0
  31. package/dist/chunk-J6A44Y3W.js.map +1 -0
  32. package/dist/chunk-LOSVFO56.cjs +145 -0
  33. package/dist/chunk-LOSVFO56.cjs.map +1 -0
  34. package/dist/chunk-MBQJ6NVJ.cjs +294 -0
  35. package/dist/chunk-MBQJ6NVJ.cjs.map +1 -0
  36. package/dist/chunk-MHGQEBPW.js +145 -0
  37. package/dist/chunk-MHGQEBPW.js.map +1 -0
  38. package/dist/chunk-QHU7FUO4.cjs +37 -0
  39. package/dist/chunk-QHU7FUO4.cjs.map +1 -0
  40. package/dist/chunk-S2IFAWJK.js +37 -0
  41. package/dist/chunk-S2IFAWJK.js.map +1 -0
  42. package/dist/chunk-TA5Y64JM.cjs +2370 -0
  43. package/dist/chunk-TA5Y64JM.cjs.map +1 -0
  44. package/dist/chunk-VYVZVUNI.js +135 -0
  45. package/dist/chunk-VYVZVUNI.js.map +1 -0
  46. package/dist/chunk-XKQMCY5Z.cjs +514 -0
  47. package/dist/chunk-XKQMCY5Z.cjs.map +1 -0
  48. package/dist/cli.cjs +273 -76
  49. package/dist/cli.cjs.map +1 -1
  50. package/dist/cli.js +269 -73
  51. package/dist/cli.js.map +1 -1
  52. package/dist/headless.cjs +8 -1880
  53. package/dist/headless.cjs.map +1 -1
  54. package/dist/headless.d.cts +3 -1
  55. package/dist/headless.d.ts +3 -1
  56. package/dist/headless.js +25 -1886
  57. package/dist/headless.js.map +1 -1
  58. package/dist/index-BIdnqtW8.d.ts +234 -0
  59. package/dist/index-Brk_uHs6.d.cts +234 -0
  60. package/dist/{index-FVXXKf2v.d.ts → index-CPMlaQsC.d.ts} +76 -153
  61. package/dist/{index-FVXXKf2v.d.cts → index-DHINe-rA.d.cts} +76 -153
  62. package/dist/index.cjs +64 -532
  63. package/dist/index.cjs.map +1 -1
  64. package/dist/index.d.cts +7 -4
  65. package/dist/index.d.ts +7 -4
  66. package/dist/index.js +63 -504
  67. package/dist/index.js.map +1 -1
  68. package/dist/{memoryAdapter-CBu7_3Pp.d.ts → memoryAdapter-Ck0ccZbF.d.cts} +3 -2
  69. package/dist/{memoryAdapter-BJjyLR-1.d.cts → memoryAdapter-DxEKaG0b.d.ts} +3 -2
  70. package/dist/placeholders-C4ftwaII.d.ts +5 -0
  71. package/dist/placeholders-x_3rt65M.d.cts +5 -0
  72. package/dist/react.cjs +1952 -3257
  73. package/dist/react.cjs.map +1 -1
  74. package/dist/react.d.cts +188 -50
  75. package/dist/react.d.ts +188 -50
  76. package/dist/react.js +1763 -3013
  77. package/dist/react.js.map +1 -1
  78. package/dist/server-handler.cjs +14 -0
  79. package/dist/server-handler.cjs.map +1 -0
  80. package/dist/server-handler.d.cts +3 -0
  81. package/dist/server-handler.d.ts +3 -0
  82. package/dist/server-handler.js +14 -0
  83. package/dist/server-handler.js.map +1 -0
  84. package/dist/server.cjs +1096 -532
  85. package/dist/server.cjs.map +1 -1
  86. package/dist/server.d.cts +77 -154
  87. package/dist/server.d.ts +77 -154
  88. package/dist/server.js +1050 -448
  89. package/dist/server.js.map +1 -1
  90. package/dist/storage.cjs +239 -232
  91. package/dist/storage.cjs.map +1 -1
  92. package/dist/storage.d.cts +21 -5
  93. package/dist/storage.d.ts +21 -5
  94. package/dist/storage.js +227 -207
  95. package/dist/storage.js.map +1 -1
  96. package/dist/styles/animated-image.css +112 -0
  97. package/dist/styles/dropzone-and-tiles.css +247 -0
  98. package/dist/styles/modals-and-dock.css +248 -0
  99. package/dist/styles/primitives.css +197 -0
  100. package/dist/styles/quality-and-inputs.css +184 -0
  101. package/dist/styles/tokens.css +123 -0
  102. package/dist/styles.css +235 -220
  103. package/dist/types-BrP-o9nd.d.cts +85 -0
  104. package/dist/types-sZ3KoFQY.d.ts +85 -0
  105. package/dist/types-yShGNQnW.d.cts +61 -0
  106. package/dist/types-yShGNQnW.d.ts +61 -0
  107. package/dist/{types-CiLY0DwB.d.cts → upload-C5Trd6Dh.d.cts} +27 -53
  108. package/dist/{types-CiLY0DwB.d.ts → upload-C5Trd6Dh.d.ts} +27 -53
  109. package/package.json +92 -32
  110. package/dist/blurService-DqsnfPTd.d.cts +0 -172
  111. package/dist/blurService-DqsnfPTd.d.ts +0 -172
package/dist/bin.js ADDED
@@ -0,0 +1,1186 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli/commands.ts
4
+ import fs4 from "fs/promises";
5
+ import path4 from "path";
6
+ import { Command } from "commander";
7
+
8
+ // src/blur/pathMapper.ts
9
+ var DEFAULT_CDN_PATTERNS = [
10
+ /^https?:\/\/[^/]+\.supabase\.co\/storage\/v1\/object\/public\/([^/]+)\/(.+)$/,
11
+ /^https?:\/\/[^/]+\.r2\.dev\/(.+)$/,
12
+ /^https?:\/\/[^/]+\.amazonaws\.com\/(.+)$/,
13
+ /^https?:\/\/[^/]+\.cloudflare\.com\/(.+)$/,
14
+ /^https?:\/\/cdn\.[^/]+\/(.+)$/
15
+ ];
16
+ function normalizeImagePath(imageSrc, options = {}) {
17
+ const originalPath = imageSrc;
18
+ let normalizedPath = imageSrc;
19
+ let isExternal = false;
20
+ try {
21
+ if (imageSrc.startsWith("data:") || imageSrc.startsWith("blob:")) {
22
+ return {
23
+ normalizedPath: imageSrc,
24
+ isExternal: false,
25
+ originalPath
26
+ };
27
+ }
28
+ const patterns = options.cdnPatterns || DEFAULT_CDN_PATTERNS;
29
+ if (imageSrc.startsWith("http://") || imageSrc.startsWith("https://")) {
30
+ const url = new URL(imageSrc);
31
+ isExternal = true;
32
+ const cleanUrlNoQuery = `${url.origin}${url.pathname}`;
33
+ for (const pattern of patterns) {
34
+ const match = cleanUrlNoQuery.match(pattern);
35
+ if (match) {
36
+ const groups = match.slice(1).filter(Boolean);
37
+ const pathPart = groups.join("/");
38
+ normalizedPath = "/" + pathPart.replace(/^\/+/, "");
39
+ break;
40
+ }
41
+ }
42
+ if (normalizedPath === imageSrc) {
43
+ normalizedPath = url.pathname;
44
+ }
45
+ } else {
46
+ normalizedPath = normalizedPath.split("?")[0].split("#")[0];
47
+ }
48
+ normalizedPath = normalizedPath.replace(/\\/g, "/").replace(/^\.\.?\//g, "");
49
+ if (!normalizedPath.startsWith("/")) {
50
+ normalizedPath = "/" + normalizedPath;
51
+ }
52
+ const prefixesToStrip = options.stripPrefixes || ["/public/"];
53
+ for (const prefix of prefixesToStrip) {
54
+ if (normalizedPath.startsWith(prefix)) {
55
+ normalizedPath = "/" + normalizedPath.slice(prefix.length).replace(/^\/+/, "");
56
+ }
57
+ }
58
+ const segments = normalizedPath.split("/").filter(Boolean);
59
+ const filename = segments[segments.length - 1];
60
+ const folder = segments.length > 1 ? segments.slice(0, -1).join("/") : void 0;
61
+ const dotIdx = filename ? filename.lastIndexOf(".") : -1;
62
+ const extension = filename && dotIdx > 0 ? filename.slice(dotIdx + 1).toLowerCase() : void 0;
63
+ return {
64
+ normalizedPath,
65
+ folder,
66
+ filename,
67
+ extension,
68
+ isExternal,
69
+ originalPath
70
+ };
71
+ } catch {
72
+ return {
73
+ normalizedPath: imageSrc,
74
+ isExternal: false,
75
+ originalPath
76
+ };
77
+ }
78
+ }
79
+
80
+ // src/cli/generator.ts
81
+ import fs2 from "fs/promises";
82
+ import path2 from "path";
83
+
84
+ // src/server/optimization/types.ts
85
+ var BLUR_PLACEHOLDER_WIDTH = 128;
86
+ var BLUR_PLACEHOLDER_FALLBACK_WIDTH = 96;
87
+ var BLUR_PLACEHOLDER_QUALITY = 80;
88
+ var BLUR_PLACEHOLDER_MAX_CHARS = 8e3;
89
+ var EDGE_COLOR_FALLBACK = "#6B7280";
90
+ var EDGE_WHITE_FALLBACK = "#F9FAFB";
91
+ var WHITE_DOMINANCE_RATIO = 1.35;
92
+
93
+ // src/server/optimization/loadSharp.ts
94
+ var SHARP_MISSING_MESSAGE = "img-upload-kit/server requires `sharp` \u2014 run `npm i sharp`";
95
+ var sharpPromise = null;
96
+ function unwrapDefault(mod) {
97
+ const record = mod;
98
+ return record && record.default !== void 0 ? record.default : mod;
99
+ }
100
+ function loadSharp() {
101
+ if (!sharpPromise) {
102
+ sharpPromise = import("sharp").then(
103
+ (mod) => unwrapDefault(mod),
104
+ (error) => {
105
+ sharpPromise = null;
106
+ throw new Error(SHARP_MISSING_MESSAGE, { cause: error });
107
+ }
108
+ );
109
+ }
110
+ return sharpPromise;
111
+ }
112
+
113
+ // src/server/optimization/blurGenerator.ts
114
+ import { encode as encodeBlurHash } from "blurhash";
115
+
116
+ // src/utils/color.ts
117
+ function hexToRgb(hex) {
118
+ const normalized = hex.replace("#", "");
119
+ const expanded = normalized.length === 3 ? normalized.split("").map((char) => char + char).join("") : normalized;
120
+ const padded = expanded.padEnd(6, "0").slice(0, 6);
121
+ const value = Number.parseInt(padded, 16);
122
+ if (Number.isNaN(value)) {
123
+ return { r: 245, g: 245, b: 245 };
124
+ }
125
+ return {
126
+ r: value >> 16 & 255,
127
+ g: value >> 8 & 255,
128
+ b: value & 255
129
+ };
130
+ }
131
+ function rgbToHex(r, g, b) {
132
+ const clamp = (v) => Math.max(0, Math.min(255, Math.round(v)));
133
+ const value = clamp(r) << 16 | clamp(g) << 8 | clamp(b);
134
+ return `#${value.toString(16).padStart(6, "0").toUpperCase()}`;
135
+ }
136
+
137
+ // src/server/optimization/colorHistogram.ts
138
+ async function extractEnhancedColorFromBuffer(buffer, mode = "balanced") {
139
+ const sharp = await loadSharp();
140
+ try {
141
+ const metadata = await sharp(buffer).metadata();
142
+ const maxSize = 400;
143
+ const needsResize = metadata.width && metadata.height && (metadata.width > maxSize || metadata.height > maxSize);
144
+ if (needsResize) {
145
+ const resized = await sharp(buffer).resize(maxSize, maxSize, {
146
+ fit: "inside",
147
+ kernel: "lanczos3",
148
+ withoutEnlargement: true
149
+ }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
150
+ return extractDominantColorHistogramAdvanced(
151
+ resized.data,
152
+ resized.info.width,
153
+ resized.info.height,
154
+ mode
155
+ );
156
+ }
157
+ const raw = await sharp(buffer).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
158
+ return extractDominantColorHistogramAdvanced(
159
+ raw.data,
160
+ raw.info.width,
161
+ raw.info.height,
162
+ mode
163
+ );
164
+ } catch {
165
+ const stats = await sharp(buffer).resize(64, 64, { fit: "cover", kernel: "lanczos3" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
166
+ return extractDominantColorHistogram(stats.data);
167
+ }
168
+ }
169
+ function extractDominantColorHistogramAdvanced(data, width, height, mode) {
170
+ const colorMap = /* @__PURE__ */ new Map();
171
+ const bucketSize = 16;
172
+ const centerWeight = 1.5;
173
+ const centerX = width / 2 || 1;
174
+ const centerY = height / 2 || 1;
175
+ let whiteWeight = 0;
176
+ for (let i = 0; i < data.length; i += 4) {
177
+ const alpha = data[i + 3];
178
+ if (alpha !== void 0 && alpha < 8) continue;
179
+ const r2 = data[i];
180
+ const g2 = data[i + 1];
181
+ const b2 = data[i + 2];
182
+ const brightness = (r2 + g2 + b2) / 3;
183
+ if (brightness < 10) continue;
184
+ const pixelIndex = i / 4;
185
+ const x = pixelIndex % width;
186
+ const y = Math.floor(pixelIndex / width);
187
+ const dx = (x - centerX) / centerX;
188
+ const dy = (y - centerY) / centerY;
189
+ const distanceFromCenter = Math.sqrt(dx * dx + dy * dy);
190
+ let weight = 1;
191
+ if (mode === "edge") {
192
+ const edgeThicknessX = Math.max(1, Math.floor(width * 0.12));
193
+ const edgeThicknessY = Math.max(1, Math.floor(height * 0.12));
194
+ const onLeftEdge = x < edgeThicknessX;
195
+ const onRightEdge = x >= width - edgeThicknessX;
196
+ const onTopEdge = y < edgeThicknessY;
197
+ const onBottomEdge = y >= height - edgeThicknessY;
198
+ if (!(onLeftEdge || onRightEdge || onTopEdge || onBottomEdge)) {
199
+ continue;
200
+ }
201
+ const distanceToNearestEdge = Math.min(
202
+ x,
203
+ y,
204
+ width - 1 - x,
205
+ height - 1 - y
206
+ );
207
+ const maxEdgeDistance = Math.max(edgeThicknessX, edgeThicknessY) || 1;
208
+ const edgeFactor = 1 - distanceToNearestEdge / maxEdgeDistance;
209
+ weight = 1 + edgeFactor * 3;
210
+ } else {
211
+ weight = distanceFromCenter < 0.5 ? centerWeight : 1;
212
+ }
213
+ const isNearWhite = r2 >= 245 && g2 >= 245 && b2 >= 245;
214
+ if (isNearWhite) {
215
+ whiteWeight += weight;
216
+ continue;
217
+ }
218
+ const bucketR = Math.floor(r2 / bucketSize) * bucketSize;
219
+ const bucketG = Math.floor(g2 / bucketSize) * bucketSize;
220
+ const bucketB = Math.floor(b2 / bucketSize) * bucketSize;
221
+ const key = `${bucketR},${bucketG},${bucketB}`;
222
+ colorMap.set(key, (colorMap.get(key) || 0) + weight);
223
+ }
224
+ if (!colorMap.size) {
225
+ return whiteWeight > 0 ? EDGE_WHITE_FALLBACK : EDGE_COLOR_FALLBACK;
226
+ }
227
+ let maxCount = 0;
228
+ let dominantColor = "64,64,80";
229
+ let totalColorWeight = 0;
230
+ colorMap.forEach((count, color) => {
231
+ totalColorWeight += count;
232
+ if (count > maxCount) {
233
+ maxCount = count;
234
+ dominantColor = color;
235
+ }
236
+ });
237
+ const whiteDominates = whiteWeight > 0 && whiteWeight >= totalColorWeight * WHITE_DOMINANCE_RATIO;
238
+ if (whiteDominates) {
239
+ return EDGE_WHITE_FALLBACK;
240
+ }
241
+ const [r, g, b] = dominantColor.split(",").map(Number);
242
+ return rgbToHex(r, g, b);
243
+ }
244
+ function extractDominantColorHistogram(data) {
245
+ const colorMap = /* @__PURE__ */ new Map();
246
+ const bucketSize = 16;
247
+ let whiteWeight = 0;
248
+ for (let i = 0; i < data.length; i += 4) {
249
+ const alpha = data[i + 3];
250
+ if (alpha !== void 0 && alpha < 8) continue;
251
+ const r2 = Math.floor(data[i] / bucketSize) * bucketSize;
252
+ const g2 = Math.floor(data[i + 1] / bucketSize) * bucketSize;
253
+ const b2 = Math.floor(data[i + 2] / bucketSize) * bucketSize;
254
+ if (r2 >= 240 && g2 >= 240 && b2 >= 240) {
255
+ whiteWeight += 1;
256
+ continue;
257
+ }
258
+ const key = `${r2},${g2},${b2}`;
259
+ colorMap.set(key, (colorMap.get(key) || 0) + 1);
260
+ }
261
+ if (!colorMap.size) {
262
+ return whiteWeight > 0 ? EDGE_WHITE_FALLBACK : EDGE_COLOR_FALLBACK;
263
+ }
264
+ let maxCount = 0;
265
+ let dominantColor = "64,64,80";
266
+ let totalColorWeight = 0;
267
+ colorMap.forEach((count, color) => {
268
+ totalColorWeight += count;
269
+ if (count > maxCount) {
270
+ maxCount = count;
271
+ dominantColor = color;
272
+ }
273
+ });
274
+ const whiteDominates = whiteWeight > 0 && whiteWeight >= totalColorWeight * WHITE_DOMINANCE_RATIO;
275
+ if (whiteDominates) {
276
+ return EDGE_WHITE_FALLBACK;
277
+ }
278
+ const [r, g, b] = dominantColor.split(",").map(Number);
279
+ return rgbToHex(r, g, b);
280
+ }
281
+
282
+ // src/server/optimization/edgeColorExtractor.ts
283
+ async function extractEdgeColorsFromBuffer(buffer) {
284
+ const sharp = await loadSharp();
285
+ try {
286
+ const sample = await sharp(buffer).resize(400, 400, {
287
+ fit: "inside",
288
+ kernel: "lanczos3",
289
+ withoutEnlargement: true
290
+ }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
291
+ return computeEdgeColorSet(
292
+ sample.data,
293
+ sample.info.width,
294
+ sample.info.height
295
+ );
296
+ } catch {
297
+ try {
298
+ const fallback = await extractEnhancedColorFromBuffer(buffer, "edge");
299
+ return {
300
+ top: fallback,
301
+ right: fallback,
302
+ bottom: fallback,
303
+ left: fallback
304
+ };
305
+ } catch {
306
+ return {
307
+ top: EDGE_COLOR_FALLBACK,
308
+ right: EDGE_COLOR_FALLBACK,
309
+ bottom: EDGE_COLOR_FALLBACK,
310
+ left: EDGE_COLOR_FALLBACK
311
+ };
312
+ }
313
+ }
314
+ }
315
+ function computeEdgeColorSet(data, width, height) {
316
+ const thicknessX = Math.max(2, Math.floor(width * 0.12));
317
+ const thicknessY = Math.max(2, Math.floor(height * 0.12));
318
+ const bucketSize = 16;
319
+ const edgeMaps = {
320
+ top: /* @__PURE__ */ new Map(),
321
+ right: /* @__PURE__ */ new Map(),
322
+ bottom: /* @__PURE__ */ new Map(),
323
+ left: /* @__PURE__ */ new Map()
324
+ };
325
+ const whiteEdgeStrength = {
326
+ top: 0,
327
+ right: 0,
328
+ bottom: 0,
329
+ left: 0
330
+ };
331
+ const accumulateEdgeSample = (edge, weight, key, isWhiteSample) => {
332
+ if (weight <= 0) return;
333
+ if (isWhiteSample) {
334
+ whiteEdgeStrength[edge] += weight;
335
+ return;
336
+ }
337
+ edgeMaps[edge].set(key, (edgeMaps[edge].get(key) || 0) + weight);
338
+ };
339
+ for (let i = 0; i < data.length; i += 4) {
340
+ const r = data[i];
341
+ const g = data[i + 1];
342
+ const b = data[i + 2];
343
+ const alpha = data[i + 3];
344
+ if (alpha !== void 0 && alpha < 8) continue;
345
+ const brightness = (r + g + b) / 3;
346
+ if (brightness < 12) continue;
347
+ const isNearWhite = r >= 245 && g >= 245 && b >= 245;
348
+ const pixelIndex = i / 4;
349
+ const x = pixelIndex % width;
350
+ const y = Math.floor(pixelIndex / width);
351
+ const bucketR = Math.floor(r / bucketSize) * bucketSize;
352
+ const bucketG = Math.floor(g / bucketSize) * bucketSize;
353
+ const bucketB = Math.floor(b / bucketSize) * bucketSize;
354
+ const key = `${bucketR},${bucketG},${bucketB}`;
355
+ const topWeight = y < thicknessY ? 1 + (thicknessY - y) / thicknessY : 0;
356
+ if (topWeight > 0) {
357
+ accumulateEdgeSample("top", topWeight, key, isNearWhite);
358
+ }
359
+ const bottomWeight = y >= height - thicknessY ? 1 + (y - (height - thicknessY)) / thicknessY : 0;
360
+ if (bottomWeight > 0) {
361
+ accumulateEdgeSample("bottom", bottomWeight, key, isNearWhite);
362
+ }
363
+ const leftWeight = x < thicknessX ? 1 + (thicknessX - x) / thicknessX : 0;
364
+ if (leftWeight > 0) {
365
+ accumulateEdgeSample("left", leftWeight, key, isNearWhite);
366
+ }
367
+ const rightWeight = x >= width - thicknessX ? 1 + (x - (width - thicknessX)) / thicknessX : 0;
368
+ if (rightWeight > 0) {
369
+ accumulateEdgeSample("right", rightWeight, key, isNearWhite);
370
+ }
371
+ }
372
+ return {
373
+ top: resolveEdgeColor(edgeMaps.top, whiteEdgeStrength.top),
374
+ right: resolveEdgeColor(edgeMaps.right, whiteEdgeStrength.right),
375
+ bottom: resolveEdgeColor(edgeMaps.bottom, whiteEdgeStrength.bottom),
376
+ left: resolveEdgeColor(edgeMaps.left, whiteEdgeStrength.left)
377
+ };
378
+ }
379
+ function resolveEdgeColor(map, whiteStrength) {
380
+ if (!map.size) {
381
+ return whiteStrength > 0 ? EDGE_WHITE_FALLBACK : EDGE_COLOR_FALLBACK;
382
+ }
383
+ let dominantColor = "";
384
+ let maxCount = 0;
385
+ let totalColorWeight = 0;
386
+ map.forEach((count, color) => {
387
+ totalColorWeight += count;
388
+ if (count > maxCount) {
389
+ maxCount = count;
390
+ dominantColor = color;
391
+ }
392
+ });
393
+ if (!dominantColor) {
394
+ return whiteStrength > 0 ? EDGE_WHITE_FALLBACK : EDGE_COLOR_FALLBACK;
395
+ }
396
+ const whiteDominates = whiteStrength > 0 && (totalColorWeight === 0 || whiteStrength >= totalColorWeight * WHITE_DOMINANCE_RATIO);
397
+ if (whiteDominates) {
398
+ return EDGE_WHITE_FALLBACK;
399
+ }
400
+ if (dominantColor.startsWith("#")) {
401
+ return dominantColor.toUpperCase();
402
+ }
403
+ const [r, g, b] = dominantColor.split(",").map(Number);
404
+ return rgbToHex(r, g, b);
405
+ }
406
+ function blendEdgeColors(edgeColors) {
407
+ const colors = Object.values(edgeColors);
408
+ if (!colors.length) {
409
+ return EDGE_COLOR_FALLBACK;
410
+ }
411
+ const totals = colors.reduce(
412
+ (acc, hex) => {
413
+ const { r, g, b } = hexToRgb(hex || EDGE_COLOR_FALLBACK);
414
+ acc.r += r;
415
+ acc.g += g;
416
+ acc.b += b;
417
+ return acc;
418
+ },
419
+ { r: 0, g: 0, b: 0 }
420
+ );
421
+ const count = colors.length;
422
+ return rgbToHex(totals.r / count, totals.g / count, totals.b / count);
423
+ }
424
+
425
+ // src/server/optimization/blurGenerator.ts
426
+ function encodeRawBlurHash(data, width, height, componentX = 4, componentY = 3) {
427
+ const clampedX = Math.max(1, Math.min(Math.round(componentX), width, 9));
428
+ const clampedY = Math.max(1, Math.min(Math.round(componentY), height, 9));
429
+ return encodeBlurHash(
430
+ new Uint8ClampedArray(data),
431
+ width,
432
+ height,
433
+ clampedX,
434
+ clampedY
435
+ );
436
+ }
437
+ async function encodeBlurHashString(buffer, componentX = 4, componentY = 3) {
438
+ const sharp = await loadSharp();
439
+ const { data, info } = await sharp(buffer).resize(32, 32, { fit: "inside" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
440
+ return encodeRawBlurHash(
441
+ data,
442
+ info.width,
443
+ info.height,
444
+ componentX,
445
+ componentY
446
+ );
447
+ }
448
+ async function generateBlurPlaceholder(buffer, options = {}) {
449
+ const targetWidth = options.width ?? BLUR_PLACEHOLDER_WIDTH;
450
+ const fallbackWidth = options.fallbackWidth ?? BLUR_PLACEHOLDER_FALLBACK_WIDTH;
451
+ const quality = options.quality ?? BLUR_PLACEHOLDER_QUALITY;
452
+ const maxChars = options.maxDataUrlChars ?? BLUR_PLACEHOLDER_MAX_CHARS;
453
+ const sharp = await loadSharp();
454
+ try {
455
+ let processBuffer = buffer;
456
+ const isSvg = /<svg[\s>]/i.test(buffer.toString("utf8", 0, 1024));
457
+ if (isSvg) {
458
+ const normalizedSvg = buffer.toString("utf8").replace(/<svg\b/gi, "<svg").replace(/<\/svg\s*>/gi, "</svg>");
459
+ processBuffer = Buffer.from(normalizedSvg, "utf8");
460
+ }
461
+ const baseSize = Math.max(targetWidth, 128);
462
+ const rawSample = await sharp(processBuffer).rotate().resize(baseSize, baseSize, { fit: "inside", withoutEnlargement: true }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
463
+ const rawInput = {
464
+ raw: {
465
+ width: rawSample.info.width,
466
+ height: rawSample.info.height,
467
+ channels: 4
468
+ }
469
+ };
470
+ const buildPlaceholderDataUrl = async (width) => {
471
+ const webpBuffer = await sharp(rawSample.data, rawInput).resize(width, width, { fit: "inside", withoutEnlargement: true }).webp({ quality, effort: 6, smartSubsample: true }).toBuffer();
472
+ return `data:image/webp;base64,${webpBuffer.toString("base64")}`;
473
+ };
474
+ let dataURL = await buildPlaceholderDataUrl(targetWidth);
475
+ if (dataURL.length > maxChars) {
476
+ dataURL = await buildPlaceholderDataUrl(fallbackWidth);
477
+ }
478
+ const edgeColors = computeEdgeColorSet(
479
+ rawSample.data,
480
+ rawSample.info.width,
481
+ rawSample.info.height
482
+ );
483
+ const color = blendEdgeColors(edgeColors);
484
+ let blurHash;
485
+ if (options.includeBlurHash) {
486
+ try {
487
+ const hashSample = rawSample.info.width <= 32 && rawSample.info.height <= 32 ? rawSample : await sharp(rawSample.data, rawInput).resize(32, 32, { fit: "inside" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
488
+ blurHash = encodeRawBlurHash(
489
+ hashSample.data,
490
+ hashSample.info.width,
491
+ hashSample.info.height
492
+ );
493
+ } catch {
494
+ blurHash = void 0;
495
+ }
496
+ }
497
+ return {
498
+ dataURL,
499
+ color,
500
+ ...blurHash && { blurHash }
501
+ };
502
+ } catch {
503
+ return generateFallbackBlur(buffer, options.includeBlurHash);
504
+ }
505
+ }
506
+ async function generateFallbackBlur(buffer, includeBlurHash) {
507
+ const sharp = await loadSharp();
508
+ const tinyBuffer = await sharp(buffer).resize(64, 64, { fit: "cover", kernel: "lanczos3" }).blur(1).webp({
509
+ quality: 75,
510
+ effort: 6,
511
+ smartSubsample: false
512
+ }).toBuffer();
513
+ const dataURL = `data:image/webp;base64,${tinyBuffer.toString("base64")}`;
514
+ const edgeColors = await extractEdgeColorsFromBuffer(buffer);
515
+ const color = blendEdgeColors(edgeColors);
516
+ let blurHash;
517
+ if (includeBlurHash) {
518
+ try {
519
+ blurHash = await encodeBlurHashString(tinyBuffer);
520
+ } catch {
521
+ blurHash = void 0;
522
+ }
523
+ }
524
+ return { dataURL, color, ...blurHash && { blurHash } };
525
+ }
526
+
527
+ // src/server/semaphore.ts
528
+ var SemaphoreQueueFullError = class extends Error {
529
+ status = 503;
530
+ constructor(message = "Server upload queue is full. Please try again later.") {
531
+ super(message);
532
+ this.name = "SemaphoreQueueFullError";
533
+ }
534
+ };
535
+ function createAbortError(signal) {
536
+ if (signal?.reason instanceof Error) {
537
+ return signal.reason;
538
+ }
539
+ const err = new Error(
540
+ typeof signal?.reason === "string" ? signal.reason : "Operation aborted"
541
+ );
542
+ err.name = "AbortError";
543
+ return err;
544
+ }
545
+ var AsyncSemaphore = class {
546
+ constructor(concurrency, options) {
547
+ this.concurrency = concurrency;
548
+ if (concurrency < 1) {
549
+ throw new Error("AsyncSemaphore concurrency must be >= 1");
550
+ }
551
+ if (typeof options === "number") {
552
+ this.maxQueue = options;
553
+ } else if (options && typeof options === "object") {
554
+ this.maxQueue = options.maxQueue;
555
+ }
556
+ }
557
+ concurrency;
558
+ activeCount = 0;
559
+ maxQueue;
560
+ queue = [];
561
+ get active() {
562
+ return this.activeCount;
563
+ }
564
+ get pending() {
565
+ return this.queue.length;
566
+ }
567
+ async run(task, options = {}) {
568
+ const { signal, timeoutMs, label = "Semaphore task" } = options;
569
+ await this.acquire(signal);
570
+ let rawTaskPromise;
571
+ try {
572
+ rawTaskPromise = Promise.resolve().then(task);
573
+ } catch (syncError) {
574
+ this.release();
575
+ throw syncError;
576
+ }
577
+ let released = false;
578
+ const releaseOnce = () => {
579
+ if (!released) {
580
+ released = true;
581
+ this.release();
582
+ }
583
+ };
584
+ void rawTaskPromise.then(
585
+ () => releaseOnce(),
586
+ (err) => {
587
+ const pending = err && typeof err === "object" && "pendingTask" in err ? err.pendingTask : void 0;
588
+ if (pending && typeof pending.then === "function") {
589
+ void pending.then(releaseOnce, releaseOnce);
590
+ } else {
591
+ releaseOnce();
592
+ }
593
+ }
594
+ );
595
+ let callerPromise = rawTaskPromise;
596
+ if (timeoutMs !== void 0 && timeoutMs > 0) {
597
+ callerPromise = withTimeout(callerPromise, timeoutMs, label);
598
+ }
599
+ if (signal) {
600
+ callerPromise = raceWithAbort(callerPromise, signal);
601
+ }
602
+ return callerPromise;
603
+ }
604
+ acquire(signal) {
605
+ if (signal?.aborted) {
606
+ return Promise.reject(createAbortError(signal));
607
+ }
608
+ if (this.activeCount < this.concurrency) {
609
+ this.activeCount += 1;
610
+ return Promise.resolve();
611
+ }
612
+ if (this.maxQueue !== void 0 && this.queue.length >= this.maxQueue) {
613
+ return Promise.reject(new SemaphoreQueueFullError());
614
+ }
615
+ return new Promise((resolve, reject) => {
616
+ const onAbort = () => {
617
+ const idx = this.queue.indexOf(entry);
618
+ if (idx !== -1) {
619
+ this.queue.splice(idx, 1);
620
+ }
621
+ entry.cancel(createAbortError(signal));
622
+ };
623
+ const entry = {
624
+ grant: () => {
625
+ signal?.removeEventListener("abort", onAbort);
626
+ this.activeCount += 1;
627
+ resolve();
628
+ },
629
+ cancel: (reason) => {
630
+ signal?.removeEventListener("abort", onAbort);
631
+ reject(reason);
632
+ }
633
+ };
634
+ signal?.addEventListener("abort", onAbort, { once: true });
635
+ this.queue.push(entry);
636
+ });
637
+ }
638
+ release() {
639
+ this.activeCount = Math.max(0, this.activeCount - 1);
640
+ const next = this.queue.shift();
641
+ next?.grant();
642
+ }
643
+ };
644
+ function raceWithAbort(task, signal) {
645
+ if (signal.aborted) {
646
+ const err = createAbortError(signal);
647
+ err.pendingTask = task.then(
648
+ () => {
649
+ },
650
+ () => {
651
+ }
652
+ );
653
+ return Promise.reject(err);
654
+ }
655
+ return new Promise((resolve, reject) => {
656
+ const onAbort = () => {
657
+ const err = createAbortError(signal);
658
+ err.pendingTask = task.then(
659
+ () => {
660
+ },
661
+ () => {
662
+ }
663
+ );
664
+ reject(err);
665
+ };
666
+ signal.addEventListener("abort", onAbort, { once: true });
667
+ task.then(
668
+ (val) => {
669
+ signal.removeEventListener("abort", onAbort);
670
+ resolve(val);
671
+ },
672
+ (err) => {
673
+ signal.removeEventListener("abort", onAbort);
674
+ reject(err);
675
+ }
676
+ );
677
+ });
678
+ }
679
+ async function withTimeout(task, timeoutMs, label) {
680
+ let timeoutHandle = null;
681
+ const settledTask = task.then(
682
+ () => {
683
+ },
684
+ () => {
685
+ }
686
+ );
687
+ try {
688
+ return await Promise.race([
689
+ task,
690
+ new Promise((_, reject) => {
691
+ timeoutHandle = setTimeout(() => {
692
+ const err = new Error(
693
+ `${label} timed out after ${timeoutMs}ms`
694
+ );
695
+ err.pendingTask = settledTask;
696
+ reject(err);
697
+ }, timeoutMs);
698
+ })
699
+ ]);
700
+ } finally {
701
+ if (timeoutHandle) {
702
+ clearTimeout(timeoutHandle);
703
+ }
704
+ }
705
+ }
706
+
707
+ // src/cli/scanner.ts
708
+ import fs from "fs/promises";
709
+ import path from "path";
710
+ var SUPPORTED_IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([
711
+ ".jpg",
712
+ ".jpeg",
713
+ ".png",
714
+ ".webp",
715
+ ".avif",
716
+ ".svg"
717
+ ]);
718
+ var IGNORED_DIRECTORIES = /* @__PURE__ */ new Set(["node_modules", ".git", "dist"]);
719
+ async function scanImageDirectory(imagesDir) {
720
+ const rootDir = path.resolve(imagesDir);
721
+ const rootStats = await fs.stat(rootDir);
722
+ if (!rootStats.isDirectory()) {
723
+ throw new Error(`Expected images path to be a directory: ${rootDir}`);
724
+ }
725
+ const results = [];
726
+ async function walk(currentDir) {
727
+ const entries = await fs.readdir(currentDir, { withFileTypes: true });
728
+ for (const entry of entries) {
729
+ const fullPath = path.join(currentDir, entry.name);
730
+ if (entry.isDirectory()) {
731
+ if (IGNORED_DIRECTORIES.has(entry.name)) {
732
+ continue;
733
+ }
734
+ await walk(fullPath);
735
+ continue;
736
+ }
737
+ if (!entry.isFile()) {
738
+ continue;
739
+ }
740
+ const ext = path.extname(entry.name).toLowerCase();
741
+ if (!SUPPORTED_IMAGE_EXTENSIONS.has(ext)) {
742
+ continue;
743
+ }
744
+ const fileStats = await fs.stat(fullPath);
745
+ const relPath = path.relative(rootDir, fullPath).split(path.sep).join("/").replace(/\\/g, "/");
746
+ const relativeKey = relPath.startsWith("/") ? relPath : `/${relPath}`;
747
+ results.push({
748
+ absolutePath: fullPath,
749
+ relativeKey,
750
+ size: fileStats.size,
751
+ mtimeMs: fileStats.mtimeMs
752
+ });
753
+ }
754
+ }
755
+ await walk(rootDir);
756
+ results.sort((a, b) => a.relativeKey.localeCompare(b.relativeKey));
757
+ return results;
758
+ }
759
+
760
+ // src/cli/generator.ts
761
+ var FALLBACK_BLURHASH = "LEHV6nWB2yk8pyo0adR*.7kCMdnj";
762
+ var MANIFEST_VERSION = "2.0";
763
+ async function loadExistingHashes(manifestPath) {
764
+ try {
765
+ const parsed = JSON.parse(await fs2.readFile(manifestPath, "utf8"));
766
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && "hashes" in parsed && parsed.hashes && typeof parsed.hashes === "object" && !Array.isArray(parsed.hashes)) {
767
+ return parsed.hashes;
768
+ }
769
+ return {};
770
+ } catch {
771
+ return {};
772
+ }
773
+ }
774
+ function canReuseCachedEntry(entry, file, includeMedium) {
775
+ if (!entry || typeof entry !== "object") return false;
776
+ if (!entry.hash || !entry.color || !entry.variants?.small) return false;
777
+ if (!entry.metadata || !entry.metadata.size || !entry.metadata.mtimeMs) {
778
+ return false;
779
+ }
780
+ if (entry.metadata.size !== String(file.size)) {
781
+ return false;
782
+ }
783
+ if (entry.metadata.mtimeMs !== String(Math.round(file.mtimeMs))) {
784
+ return false;
785
+ }
786
+ if (includeMedium && !entry.variants.medium) return false;
787
+ return true;
788
+ }
789
+ async function resolveImageDimensions(buffer, isSvg) {
790
+ const sharp = await loadSharp();
791
+ const metadata = await sharp(buffer).metadata();
792
+ let { width, height } = metadata;
793
+ if ((!width || !height) && isSvg) {
794
+ const svgText = buffer.toString("utf8");
795
+ const match = svgText.match(
796
+ /viewBox\s*=\s*["']\s*([\d.-]+)[\s,]+([\d.-]+)[\s,]+([\d.-]+)[\s,]+([\d.-]+)\s*["']/i
797
+ );
798
+ if (match) {
799
+ width = Math.round(Number.parseFloat(match[3]));
800
+ height = Math.round(Number.parseFloat(match[4]));
801
+ }
802
+ }
803
+ return { width, height };
804
+ }
805
+ async function generateBlurManifest(imagesDir, manifestFile, options = {}) {
806
+ const outputPath = path2.resolve(manifestFile);
807
+ const concurrency = Math.max(1, Math.floor(options.concurrency ?? 4));
808
+ const includeMedium = options.includeMedium ?? true;
809
+ const force = Boolean(options.force);
810
+ const files = await scanImageDirectory(imagesDir);
811
+ const existingHashes = force ? {} : await loadExistingHashes(outputPath);
812
+ const semaphore = new AsyncSemaphore(concurrency);
813
+ const generatedEntries = /* @__PURE__ */ new Map();
814
+ let processed = 0;
815
+ let cached = 0;
816
+ let failed = 0;
817
+ let done = 0;
818
+ const total = files.length;
819
+ await Promise.all(
820
+ files.map(
821
+ (file) => semaphore.run(async () => {
822
+ const prior = force ? void 0 : existingHashes[file.relativeKey];
823
+ try {
824
+ if (canReuseCachedEntry(prior, file, includeMedium)) {
825
+ generatedEntries.set(file.relativeKey, prior);
826
+ cached += 1;
827
+ return;
828
+ }
829
+ const buffer = await fs2.readFile(file.absolutePath);
830
+ const isSvg = file.relativeKey.toLowerCase().endsWith(".svg");
831
+ const { width, height } = await resolveImageDimensions(buffer, isSvg);
832
+ const small = await generateBlurPlaceholder(buffer, {
833
+ width: 64,
834
+ fallbackWidth: 48,
835
+ includeBlurHash: true
836
+ });
837
+ const medium = includeMedium ? await generateBlurPlaceholder(buffer, {
838
+ width: 128,
839
+ fallbackWidth: 96,
840
+ includeBlurHash: false
841
+ }) : void 0;
842
+ const hash = small.blurHash || await encodeBlurHashString(buffer).catch(() => FALLBACK_BLURHASH);
843
+ generatedEntries.set(file.relativeKey, {
844
+ hash,
845
+ variants: {
846
+ small: small.dataURL,
847
+ ...medium ? { medium: medium.dataURL } : {}
848
+ },
849
+ color: small.color,
850
+ ...width ? { width } : {},
851
+ ...height ? { height } : {},
852
+ metadata: {
853
+ size: String(file.size),
854
+ mtimeMs: String(Math.round(file.mtimeMs))
855
+ }
856
+ });
857
+ processed += 1;
858
+ } catch (err) {
859
+ failed += 1;
860
+ const existing = existingHashes[file.relativeKey];
861
+ if (existing) {
862
+ generatedEntries.set(file.relativeKey, existing);
863
+ }
864
+ const reason = err instanceof Error ? err.message : String(err);
865
+ console.warn(
866
+ `Warning: Failed to generate blur manifest entry for "${file.relativeKey}": ${reason}`
867
+ );
868
+ } finally {
869
+ done += 1;
870
+ options.onProgress?.(done, total, file.relativeKey);
871
+ }
872
+ })
873
+ )
874
+ );
875
+ const orderedHashes = {};
876
+ for (const file of files) {
877
+ const entry = generatedEntries.get(file.relativeKey);
878
+ if (entry) orderedHashes[file.relativeKey] = entry;
879
+ }
880
+ const manifest = {
881
+ version: MANIFEST_VERSION,
882
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
883
+ totalImages: Object.keys(orderedHashes).length,
884
+ processedImages: processed,
885
+ cachedImages: cached,
886
+ failedImages: failed,
887
+ hashes: orderedHashes
888
+ };
889
+ await fs2.mkdir(path2.dirname(outputPath), { recursive: true });
890
+ await fs2.writeFile(outputPath, `${JSON.stringify(manifest, null, 2)}
891
+ `, "utf8");
892
+ return { manifest, processed, cached, failed, outputPath };
893
+ }
894
+
895
+ // src/cli/validation.ts
896
+ import fs3 from "fs/promises";
897
+ import path3 from "path";
898
+ var HEX_COLOR_RE = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
899
+ function isPlainObject(value) {
900
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
901
+ }
902
+ function toNormalizedKey(key) {
903
+ return normalizeImagePath(key, { stripPrefixes: [] }).normalizedPath;
904
+ }
905
+ function failValidation(error) {
906
+ return { valid: false, totalEntries: 0, errors: [error], warnings: [] };
907
+ }
908
+ function isDataImageUrl(value) {
909
+ return typeof value === "string" && value.startsWith("data:image/");
910
+ }
911
+ function computeKeyDiff(files, hashes) {
912
+ const diskKeys = new Set(files.map((f) => f.relativeKey));
913
+ const manifestKeys = Object.keys(hashes);
914
+ const normalizedSet = new Set(manifestKeys.map(toNormalizedKey));
915
+ const missingKeys = files.map((f) => f.relativeKey).filter((k) => !(k in hashes) && !normalizedSet.has(k));
916
+ const orphanedKeys = manifestKeys.filter(
917
+ (k) => !diskKeys.has(k) && !diskKeys.has(toNormalizedKey(k))
918
+ );
919
+ return { missingKeys, orphanedKeys };
920
+ }
921
+ async function readManifestSafe(manifestFile) {
922
+ try {
923
+ const parsed = JSON.parse(await fs3.readFile(path3.resolve(manifestFile), "utf8"));
924
+ if (isPlainObject(parsed) && isPlainObject(parsed.hashes)) {
925
+ return {
926
+ exists: true,
927
+ manifest: parsed,
928
+ hashes: parsed.hashes
929
+ };
930
+ }
931
+ return { exists: true, hashes: {} };
932
+ } catch (err) {
933
+ return { exists: err.code !== "ENOENT", hashes: {} };
934
+ }
935
+ }
936
+ function validateEntry(key, value, errors, warnings) {
937
+ if (!key.startsWith("/")) warnings.push(`Entry key "${key}" should start with "/"`);
938
+ if (!isPlainObject(value)) {
939
+ errors.push(`Entry "${key}" must be an object`);
940
+ return;
941
+ }
942
+ const entry = value;
943
+ if (typeof entry.hash !== "string" || entry.hash.trim().length < 6) {
944
+ errors.push(`Entry "${key}" has missing or invalid BlurHash "hash"`);
945
+ }
946
+ if (typeof entry.color !== "string" || !HEX_COLOR_RE.test(entry.color)) {
947
+ errors.push(`Entry "${key}" has missing or invalid hex "color"`);
948
+ }
949
+ if (!isPlainObject(entry.variants)) {
950
+ errors.push(`Entry "${key}" is missing "variants" object`);
951
+ return;
952
+ }
953
+ if (!isDataImageUrl(entry.variants.small)) {
954
+ errors.push(`Entry "${key}" is missing valid "variants.small" data URL`);
955
+ }
956
+ if (entry.variants.medium !== void 0 && !isDataImageUrl(entry.variants.medium)) {
957
+ errors.push(`Entry "${key}" has invalid "variants.medium" data URL`);
958
+ }
959
+ }
960
+ async function validateBlurHashFile(manifestFile) {
961
+ const resolvedPath = path3.resolve(manifestFile);
962
+ let raw;
963
+ try {
964
+ raw = await fs3.readFile(resolvedPath, "utf8");
965
+ } catch {
966
+ return failValidation(`Manifest file not found or unreadable: ${resolvedPath}`);
967
+ }
968
+ let parsed;
969
+ try {
970
+ parsed = JSON.parse(raw);
971
+ } catch (err) {
972
+ return failValidation(`Invalid JSON in manifest: ${err.message}`);
973
+ }
974
+ if (!isPlainObject(parsed)) return failValidation("Manifest root must be a JSON object");
975
+ if (!isPlainObject(parsed.hashes)) return failValidation('Manifest is missing a valid "hashes" object');
976
+ const errors = [];
977
+ const warnings = [];
978
+ if (typeof parsed.version !== "string" || !parsed.version.trim()) {
979
+ warnings.push('Manifest is missing a "version" string');
980
+ }
981
+ if (typeof parsed.generatedAt !== "string" || Number.isNaN(Date.parse(parsed.generatedAt))) {
982
+ warnings.push('Manifest has missing or invalid "generatedAt" timestamp');
983
+ }
984
+ const entries = Object.entries(parsed.hashes);
985
+ if (typeof parsed.totalImages === "number" && parsed.totalImages !== entries.length) {
986
+ warnings.push(`Manifest totalImages (${parsed.totalImages}) differs from count (${entries.length})`);
987
+ }
988
+ if (entries.length === 0) warnings.push("Manifest contains 0 image entries");
989
+ for (const [key, value] of entries) validateEntry(key, value, errors, warnings);
990
+ return { valid: errors.length === 0, totalEntries: entries.length, errors, warnings };
991
+ }
992
+ async function autoSyncCheck(imagesDir, manifestFile) {
993
+ const [files, loaded] = await Promise.all([scanImageDirectory(imagesDir), readManifestSafe(manifestFile)]);
994
+ const { missingKeys, orphanedKeys } = computeKeyDiff(files, loaded.hashes);
995
+ const inSync = loaded.exists && missingKeys.length === 0 && orphanedKeys.length === 0;
996
+ return { inSync, totalFiles: files.length, totalEntries: Object.keys(loaded.hashes).length, missingKeys, orphanedKeys };
997
+ }
998
+ async function cleanupOrphanedHashes(imagesDir, manifestFile, options) {
999
+ const dryRun = Boolean(options?.dryRun);
1000
+ const resolvedPath = path3.resolve(manifestFile);
1001
+ const [files, loaded] = await Promise.all([scanImageDirectory(imagesDir), readManifestSafe(resolvedPath)]);
1002
+ if (!loaded.exists || !loaded.manifest) return { removedCount: 0, removedKeys: [], dryRun };
1003
+ const { orphanedKeys } = computeKeyDiff(files, loaded.hashes);
1004
+ if (!dryRun && orphanedKeys.length > 0) {
1005
+ const orphanSet = new Set(orphanedKeys);
1006
+ const hashes = Object.fromEntries(Object.entries(loaded.hashes).filter(([k]) => !orphanSet.has(k)));
1007
+ const updated = { ...loaded.manifest, generatedAt: (/* @__PURE__ */ new Date()).toISOString(), totalImages: Object.keys(hashes).length, hashes };
1008
+ await fs3.writeFile(resolvedPath, `${JSON.stringify(updated, null, 2)}
1009
+ `, "utf8");
1010
+ }
1011
+ return { removedCount: orphanedKeys.length, removedKeys: orphanedKeys, dryRun };
1012
+ }
1013
+ async function generateHealthReport(imagesDir, manifestFile) {
1014
+ const [validation, loaded] = await Promise.all([validateBlurHashFile(manifestFile), readManifestSafe(manifestFile)]);
1015
+ const errors = [...validation.errors];
1016
+ const warnings = [...validation.warnings];
1017
+ let files = [];
1018
+ try {
1019
+ files = await scanImageDirectory(imagesDir);
1020
+ } catch (err) {
1021
+ errors.push(`Failed to scan "${path3.resolve(imagesDir)}": ${err.message}`);
1022
+ }
1023
+ const { missingKeys, orphanedKeys } = computeKeyDiff(files, loaded.hashes);
1024
+ if (missingKeys.length > 0) warnings.push(`${missingKeys.length} missing image(s)`);
1025
+ if (orphanedKeys.length > 0) warnings.push(`${orphanedKeys.length} orphaned entry(ies)`);
1026
+ const totalImagesOnDisk = files.length;
1027
+ const covered = Math.max(0, totalImagesOnDisk - missingKeys.length);
1028
+ const coveragePercent = totalImagesOnDisk === 0 ? validation.valid ? 100 : 0 : Number((covered / totalImagesOnDisk * 100).toFixed(2));
1029
+ return {
1030
+ validManifest: validation.valid,
1031
+ totalImagesOnDisk,
1032
+ totalManifestEntries: validation.totalEntries,
1033
+ coveragePercent,
1034
+ missingKeys,
1035
+ orphanedKeys,
1036
+ errors,
1037
+ warnings
1038
+ };
1039
+ }
1040
+
1041
+ // src/cli/commands.ts
1042
+ var CLI_VERSION = "2.0.0".length > 0 ? "2.0.0" : "2.0.0";
1043
+ var DEFAULT_IMAGES_DIR = "public/images";
1044
+ var DEFAULT_MANIFEST_FILE = "src/blur-hashes.json";
1045
+ function findManifestEntry(hashes, imageKey) {
1046
+ const candidates = [
1047
+ imageKey,
1048
+ normalizeImagePath(imageKey).normalizedPath,
1049
+ normalizeImagePath(imageKey, { stripPrefixes: [] }).normalizedPath
1050
+ ];
1051
+ for (const candidate of candidates) {
1052
+ if (hashes[candidate]) return { key: candidate, entry: hashes[candidate] };
1053
+ const noSlash = candidate.replace(/^\/+/, "");
1054
+ if (hashes[noSlash]) return { key: noSlash, entry: hashes[noSlash] };
1055
+ }
1056
+ return null;
1057
+ }
1058
+ function createCliProgram() {
1059
+ const program = new Command();
1060
+ program.name("img-upload-kit").description("LQIP, BlurHash, and dominant color manifest CLI tooling").version(CLI_VERSION);
1061
+ program.command("generate").description("Scan an image directory and generate a blur-hashes JSON manifest").option("-d, --dir <dir>", "Source images directory", DEFAULT_IMAGES_DIR).option("-o, --out <file>", "Output manifest JSON path", DEFAULT_MANIFEST_FILE).option("-f, --force", "Force regenerate all entries ignoring cache", false).option("-c, --concurrency <n>", "Max parallel processing workers", "4").option("--no-medium", "Skip generating medium (128px) LQIP variants").action(
1062
+ async (opts) => {
1063
+ const concurrency = Math.max(1, Number.parseInt(opts.concurrency, 10) || 4);
1064
+ const result = await generateBlurManifest(opts.dir, opts.out, {
1065
+ concurrency,
1066
+ force: Boolean(opts.force),
1067
+ includeMedium: opts.medium
1068
+ });
1069
+ console.log(
1070
+ `Generated ${result.outputPath} (total: ${result.manifest.totalImages}, processed: ${result.processed}, cached: ${result.cached}, failed: ${result.failed})`
1071
+ );
1072
+ }
1073
+ );
1074
+ program.command("validate").description("Validate a blur manifest JSON file").option("-f, --file <file>", "Manifest JSON path", DEFAULT_MANIFEST_FILE).action(async (opts) => {
1075
+ const result = await validateBlurHashFile(opts.file);
1076
+ for (const warning of result.warnings) console.warn(`Warning: ${warning}`);
1077
+ if (!result.valid) {
1078
+ for (const error of result.errors) console.error(`Error: ${error}`);
1079
+ process.exitCode = 1;
1080
+ return;
1081
+ }
1082
+ console.log(`Manifest is valid (${result.totalEntries} entries).`);
1083
+ });
1084
+ program.command("sync").description("Check whether disk images and manifest entries are in sync").option("-d, --dir <dir>", "Source images directory", DEFAULT_IMAGES_DIR).option("-f, --file <file>", "Manifest JSON path", DEFAULT_MANIFEST_FILE).option("--fix", "Generate missing and remove orphaned blur manifest entries automatically", false).action(async (opts) => {
1085
+ const sync = await autoSyncCheck(opts.dir, opts.file);
1086
+ if (sync.inSync) {
1087
+ console.log(`In sync (${sync.totalEntries} entries).`);
1088
+ return;
1089
+ }
1090
+ console.log(
1091
+ `Out of sync: ${sync.missingKeys.length} missing, ${sync.orphanedKeys.length} orphaned.`
1092
+ );
1093
+ if (opts.fix) {
1094
+ if (sync.missingKeys.length > 0) {
1095
+ const result = await generateBlurManifest(opts.dir, opts.file);
1096
+ console.log(
1097
+ `Synced manifest (processed: ${result.processed}, cached: ${result.cached}, removed orphans: ${sync.orphanedKeys.length}).`
1098
+ );
1099
+ return;
1100
+ }
1101
+ if (sync.orphanedKeys.length > 0) {
1102
+ const cleaned = await cleanupOrphanedHashes(opts.dir, opts.file);
1103
+ console.log(
1104
+ `Synced manifest (removed ${cleaned.removedCount} orphaned entries).`
1105
+ );
1106
+ return;
1107
+ }
1108
+ }
1109
+ if (!opts.fix) process.exitCode = 1;
1110
+ });
1111
+ program.command("cleanup").description("Remove orphaned entries from the blur manifest").option("-d, --dir <dir>", "Source images directory", DEFAULT_IMAGES_DIR).option("-f, --file <file>", "Manifest JSON path", DEFAULT_MANIFEST_FILE).option("--dry-run", "Preview orphaned keys without writing changes", false).action(async (opts) => {
1112
+ const result = await cleanupOrphanedHashes(opts.dir, opts.file, {
1113
+ dryRun: Boolean(opts.dryRun)
1114
+ });
1115
+ const prefix = result.dryRun ? "[Dry Run] Would remove" : "Removed";
1116
+ console.log(`${prefix} ${result.removedCount} orphaned entries.`);
1117
+ });
1118
+ program.command("health").description("Generate a comprehensive manifest health and coverage report").option("-d, --dir <dir>", "Source images directory", DEFAULT_IMAGES_DIR).option("-f, --file <file>", "Manifest JSON path", DEFAULT_MANIFEST_FILE).action(async (opts) => {
1119
+ const report = await generateHealthReport(opts.dir, opts.file);
1120
+ console.log(
1121
+ `Health: valid=${report.validManifest}, coverage=${report.coveragePercent}% (${report.totalManifestEntries} entries / ${report.totalImagesOnDisk} disk images)`
1122
+ );
1123
+ for (const warning of report.warnings) console.warn(`Warning: ${warning}`);
1124
+ for (const error of report.errors) console.error(`Error: ${error}`);
1125
+ if (!report.validManifest || report.errors.length > 0) {
1126
+ process.exitCode = 1;
1127
+ }
1128
+ });
1129
+ program.command("inspect <imageKey>").description("Inspect a single image entry inside the blur manifest").option("-f, --file <file>", "Manifest JSON path", DEFAULT_MANIFEST_FILE).action(async (imageKey, opts) => {
1130
+ const raw = await fs4.readFile(path4.resolve(opts.file), "utf8");
1131
+ const parsed = JSON.parse(raw);
1132
+ const match = parsed.hashes ? findManifestEntry(parsed.hashes, imageKey) : null;
1133
+ if (!match) {
1134
+ console.error(`Entry not found for key: ${imageKey}`);
1135
+ process.exitCode = 1;
1136
+ return;
1137
+ }
1138
+ console.log(
1139
+ JSON.stringify(
1140
+ {
1141
+ key: match.key,
1142
+ hash: match.entry.hash,
1143
+ color: match.entry.color,
1144
+ width: match.entry.width,
1145
+ height: match.entry.height,
1146
+ variants: {
1147
+ smallChars: match.entry.variants.small.length,
1148
+ mediumChars: match.entry.variants.medium?.length ?? 0,
1149
+ hasMedium: Boolean(match.entry.variants.medium)
1150
+ },
1151
+ metadata: match.entry.metadata
1152
+ },
1153
+ null,
1154
+ 2
1155
+ )
1156
+ );
1157
+ });
1158
+ return program;
1159
+ }
1160
+
1161
+ // src/cli/index.ts
1162
+ var CLI_SUBCOMMANDS = /* @__PURE__ */ new Set([
1163
+ "generate",
1164
+ "validate",
1165
+ "sync",
1166
+ "cleanup",
1167
+ "health",
1168
+ "inspect",
1169
+ "help"
1170
+ ]);
1171
+ async function runCli(argv) {
1172
+ const program = createCliProgram();
1173
+ if (!argv) {
1174
+ await program.parseAsync(process.argv);
1175
+ return;
1176
+ }
1177
+ const isUserArgs = argv.length === 0 || CLI_SUBCOMMANDS.has(argv[0]) || argv[0].startsWith("-");
1178
+ await program.parseAsync(argv, isUserArgs ? { from: "user" } : void 0);
1179
+ }
1180
+
1181
+ // src/cli/bin.ts
1182
+ void runCli().catch((err) => {
1183
+ console.error(err instanceof Error ? err.message : String(err));
1184
+ process.exitCode = 1;
1185
+ });
1186
+ //# sourceMappingURL=bin.js.map