img-upload-kit 0.0.0-stage → 1.1.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.
- package/CHANGELOG.md +74 -0
- package/LICENSE +21 -0
- package/README.md +258 -2
- package/dist/blur.cjs +413 -0
- package/dist/blur.cjs.map +1 -0
- package/dist/blur.d.cts +17 -0
- package/dist/blur.d.ts +17 -0
- package/dist/blur.js +395 -0
- package/dist/blur.js.map +1 -0
- package/dist/blurService-DqsnfPTd.d.cts +172 -0
- package/dist/blurService-DqsnfPTd.d.ts +172 -0
- package/dist/cli.cjs +1044 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.cts +99 -0
- package/dist/cli.d.ts +99 -0
- package/dist/cli.js +998 -0
- package/dist/cli.js.map +1 -0
- package/dist/headless.cjs +1899 -0
- package/dist/headless.cjs.map +1 -0
- package/dist/headless.d.cts +2 -0
- package/dist/headless.d.ts +2 -0
- package/dist/headless.js +1888 -0
- package/dist/headless.js.map +1 -0
- package/dist/index-FVXXKf2v.d.cts +712 -0
- package/dist/index-FVXXKf2v.d.ts +712 -0
- package/dist/index.cjs +533 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +40 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.js +506 -0
- package/dist/index.js.map +1 -0
- package/dist/memoryAdapter-BJjyLR-1.d.cts +24 -0
- package/dist/memoryAdapter-CBu7_3Pp.d.ts +24 -0
- package/dist/react.cjs +5084 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.d.cts +338 -0
- package/dist/react.d.ts +338 -0
- package/dist/react.js +5029 -0
- package/dist/react.js.map +1 -0
- package/dist/server.cjs +1237 -0
- package/dist/server.cjs.map +1 -0
- package/dist/server.d.cts +219 -0
- package/dist/server.d.ts +219 -0
- package/dist/server.js +1199 -0
- package/dist/server.js.map +1 -0
- package/dist/storage.cjs +440 -0
- package/dist/storage.cjs.map +1 -0
- package/dist/storage.d.cts +147 -0
- package/dist/storage.d.ts +147 -0
- package/dist/storage.js +427 -0
- package/dist/storage.js.map +1 -0
- package/dist/styles.css +1107 -0
- package/dist/types-CiLY0DwB.d.cts +180 -0
- package/dist/types-CiLY0DwB.d.ts +180 -0
- package/package.json +138 -4
package/dist/cli.js
ADDED
|
@@ -0,0 +1,998 @@
|
|
|
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 pathPart = match[match.length - 1];
|
|
37
|
+
normalizedPath = "/" + pathPart.replace(/^\/+/, "");
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (normalizedPath === imageSrc) {
|
|
42
|
+
normalizedPath = url.pathname;
|
|
43
|
+
}
|
|
44
|
+
} else {
|
|
45
|
+
normalizedPath = normalizedPath.split("?")[0].split("#")[0];
|
|
46
|
+
}
|
|
47
|
+
normalizedPath = normalizedPath.replace(/\\/g, "/").replace(/^\.\.?\//g, "");
|
|
48
|
+
if (!normalizedPath.startsWith("/")) {
|
|
49
|
+
normalizedPath = "/" + normalizedPath;
|
|
50
|
+
}
|
|
51
|
+
const prefixesToStrip = options.stripPrefixes || ["/public/"];
|
|
52
|
+
for (const prefix of prefixesToStrip) {
|
|
53
|
+
if (normalizedPath.startsWith(prefix)) {
|
|
54
|
+
normalizedPath = "/" + normalizedPath.slice(prefix.length).replace(/^\/+/, "");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const segments = normalizedPath.split("/").filter(Boolean);
|
|
58
|
+
const filename = segments[segments.length - 1];
|
|
59
|
+
const folder = segments.length > 1 ? segments.slice(0, -1).join("/") : void 0;
|
|
60
|
+
const dotIdx = filename ? filename.lastIndexOf(".") : -1;
|
|
61
|
+
const extension = filename && dotIdx > 0 ? filename.slice(dotIdx + 1).toLowerCase() : void 0;
|
|
62
|
+
return {
|
|
63
|
+
normalizedPath,
|
|
64
|
+
folder,
|
|
65
|
+
filename,
|
|
66
|
+
extension,
|
|
67
|
+
isExternal,
|
|
68
|
+
originalPath
|
|
69
|
+
};
|
|
70
|
+
} catch {
|
|
71
|
+
return {
|
|
72
|
+
normalizedPath: imageSrc,
|
|
73
|
+
isExternal: false,
|
|
74
|
+
originalPath
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// src/cli/generator.ts
|
|
80
|
+
import fs2 from "fs/promises";
|
|
81
|
+
import path2 from "path";
|
|
82
|
+
import sharp4 from "sharp";
|
|
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/blurGenerator.ts
|
|
94
|
+
import { encode as encodeBlurHash } from "blurhash";
|
|
95
|
+
import lqip from "lqip-modern";
|
|
96
|
+
import sharp3 from "sharp";
|
|
97
|
+
|
|
98
|
+
// src/server/optimization/edgeColorExtractor.ts
|
|
99
|
+
import sharp2 from "sharp";
|
|
100
|
+
|
|
101
|
+
// src/utils/color.ts
|
|
102
|
+
function hexToRgb(hex) {
|
|
103
|
+
const normalized = hex.replace("#", "");
|
|
104
|
+
const expanded = normalized.length === 3 ? normalized.split("").map((char) => char + char).join("") : normalized;
|
|
105
|
+
const padded = expanded.padEnd(6, "0").slice(0, 6);
|
|
106
|
+
const value = Number.parseInt(padded, 16);
|
|
107
|
+
if (Number.isNaN(value)) {
|
|
108
|
+
return { r: 245, g: 245, b: 245 };
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
r: value >> 16 & 255,
|
|
112
|
+
g: value >> 8 & 255,
|
|
113
|
+
b: value & 255
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
function rgbToHex(r, g, b) {
|
|
117
|
+
const clamp = (v) => Math.max(0, Math.min(255, Math.round(v)));
|
|
118
|
+
const value = clamp(r) << 16 | clamp(g) << 8 | clamp(b);
|
|
119
|
+
return `#${value.toString(16).padStart(6, "0").toUpperCase()}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// src/server/optimization/colorHistogram.ts
|
|
123
|
+
import sharp from "sharp";
|
|
124
|
+
async function extractEnhancedColorFromBuffer(buffer, mode = "balanced") {
|
|
125
|
+
try {
|
|
126
|
+
const metadata = await sharp(buffer).metadata();
|
|
127
|
+
const maxSize = 400;
|
|
128
|
+
const needsResize = metadata.width && metadata.height && (metadata.width > maxSize || metadata.height > maxSize);
|
|
129
|
+
if (needsResize) {
|
|
130
|
+
const resized = await sharp(buffer).resize(maxSize, maxSize, {
|
|
131
|
+
fit: "inside",
|
|
132
|
+
kernel: "lanczos3",
|
|
133
|
+
withoutEnlargement: true
|
|
134
|
+
}).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
135
|
+
return extractDominantColorHistogramAdvanced(
|
|
136
|
+
resized.data,
|
|
137
|
+
resized.info.width,
|
|
138
|
+
resized.info.height,
|
|
139
|
+
mode
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
const raw = await sharp(buffer).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
143
|
+
return extractDominantColorHistogramAdvanced(
|
|
144
|
+
raw.data,
|
|
145
|
+
raw.info.width,
|
|
146
|
+
raw.info.height,
|
|
147
|
+
mode
|
|
148
|
+
);
|
|
149
|
+
} catch {
|
|
150
|
+
const stats = await sharp(buffer).resize(64, 64, { fit: "cover", kernel: "lanczos3" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
151
|
+
return extractDominantColorHistogram(stats.data);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function extractDominantColorHistogramAdvanced(data, width, height, mode) {
|
|
155
|
+
const colorMap = /* @__PURE__ */ new Map();
|
|
156
|
+
const bucketSize = 16;
|
|
157
|
+
const centerWeight = 1.5;
|
|
158
|
+
const centerX = width / 2 || 1;
|
|
159
|
+
const centerY = height / 2 || 1;
|
|
160
|
+
let whiteWeight = 0;
|
|
161
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
162
|
+
const alpha = data[i + 3];
|
|
163
|
+
if (alpha !== void 0 && alpha < 8) continue;
|
|
164
|
+
const r2 = data[i];
|
|
165
|
+
const g2 = data[i + 1];
|
|
166
|
+
const b2 = data[i + 2];
|
|
167
|
+
const brightness = (r2 + g2 + b2) / 3;
|
|
168
|
+
if (brightness < 10) continue;
|
|
169
|
+
const pixelIndex = i / 4;
|
|
170
|
+
const x = pixelIndex % width;
|
|
171
|
+
const y = Math.floor(pixelIndex / width);
|
|
172
|
+
const dx = (x - centerX) / centerX;
|
|
173
|
+
const dy = (y - centerY) / centerY;
|
|
174
|
+
const distanceFromCenter = Math.sqrt(dx * dx + dy * dy);
|
|
175
|
+
let weight = 1;
|
|
176
|
+
if (mode === "edge") {
|
|
177
|
+
const edgeThicknessX = Math.max(1, Math.floor(width * 0.12));
|
|
178
|
+
const edgeThicknessY = Math.max(1, Math.floor(height * 0.12));
|
|
179
|
+
const onLeftEdge = x < edgeThicknessX;
|
|
180
|
+
const onRightEdge = x >= width - edgeThicknessX;
|
|
181
|
+
const onTopEdge = y < edgeThicknessY;
|
|
182
|
+
const onBottomEdge = y >= height - edgeThicknessY;
|
|
183
|
+
if (!(onLeftEdge || onRightEdge || onTopEdge || onBottomEdge)) {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const distanceToNearestEdge = Math.min(
|
|
187
|
+
x,
|
|
188
|
+
y,
|
|
189
|
+
width - 1 - x,
|
|
190
|
+
height - 1 - y
|
|
191
|
+
);
|
|
192
|
+
const maxEdgeDistance = Math.max(edgeThicknessX, edgeThicknessY) || 1;
|
|
193
|
+
const edgeFactor = 1 - distanceToNearestEdge / maxEdgeDistance;
|
|
194
|
+
weight = 1 + edgeFactor * 3;
|
|
195
|
+
} else {
|
|
196
|
+
weight = distanceFromCenter < 0.5 ? centerWeight : 1;
|
|
197
|
+
}
|
|
198
|
+
const isNearWhite = r2 >= 245 && g2 >= 245 && b2 >= 245;
|
|
199
|
+
if (isNearWhite) {
|
|
200
|
+
whiteWeight += weight;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
const bucketR = Math.floor(r2 / bucketSize) * bucketSize;
|
|
204
|
+
const bucketG = Math.floor(g2 / bucketSize) * bucketSize;
|
|
205
|
+
const bucketB = Math.floor(b2 / bucketSize) * bucketSize;
|
|
206
|
+
const key = `${bucketR},${bucketG},${bucketB}`;
|
|
207
|
+
colorMap.set(key, (colorMap.get(key) || 0) + weight);
|
|
208
|
+
}
|
|
209
|
+
if (!colorMap.size) {
|
|
210
|
+
return whiteWeight > 0 ? EDGE_WHITE_FALLBACK : EDGE_COLOR_FALLBACK;
|
|
211
|
+
}
|
|
212
|
+
let maxCount = 0;
|
|
213
|
+
let dominantColor = "64,64,80";
|
|
214
|
+
let totalColorWeight = 0;
|
|
215
|
+
colorMap.forEach((count, color) => {
|
|
216
|
+
totalColorWeight += count;
|
|
217
|
+
if (count > maxCount) {
|
|
218
|
+
maxCount = count;
|
|
219
|
+
dominantColor = color;
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
const whiteDominates = whiteWeight > 0 && whiteWeight >= totalColorWeight * WHITE_DOMINANCE_RATIO;
|
|
223
|
+
if (whiteDominates) {
|
|
224
|
+
return EDGE_WHITE_FALLBACK;
|
|
225
|
+
}
|
|
226
|
+
const [r, g, b] = dominantColor.split(",").map(Number);
|
|
227
|
+
return rgbToHex(r, g, b);
|
|
228
|
+
}
|
|
229
|
+
function extractDominantColorHistogram(data) {
|
|
230
|
+
const colorMap = /* @__PURE__ */ new Map();
|
|
231
|
+
const bucketSize = 16;
|
|
232
|
+
let whiteWeight = 0;
|
|
233
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
234
|
+
const alpha = data[i + 3];
|
|
235
|
+
if (alpha !== void 0 && alpha < 8) continue;
|
|
236
|
+
const r2 = Math.floor(data[i] / bucketSize) * bucketSize;
|
|
237
|
+
const g2 = Math.floor(data[i + 1] / bucketSize) * bucketSize;
|
|
238
|
+
const b2 = Math.floor(data[i + 2] / bucketSize) * bucketSize;
|
|
239
|
+
if (r2 >= 240 && g2 >= 240 && b2 >= 240) {
|
|
240
|
+
whiteWeight += 1;
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
const key = `${r2},${g2},${b2}`;
|
|
244
|
+
colorMap.set(key, (colorMap.get(key) || 0) + 1);
|
|
245
|
+
}
|
|
246
|
+
if (!colorMap.size) {
|
|
247
|
+
return whiteWeight > 0 ? EDGE_WHITE_FALLBACK : EDGE_COLOR_FALLBACK;
|
|
248
|
+
}
|
|
249
|
+
let maxCount = 0;
|
|
250
|
+
let dominantColor = "64,64,80";
|
|
251
|
+
let totalColorWeight = 0;
|
|
252
|
+
colorMap.forEach((count, color) => {
|
|
253
|
+
totalColorWeight += count;
|
|
254
|
+
if (count > maxCount) {
|
|
255
|
+
maxCount = count;
|
|
256
|
+
dominantColor = color;
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
const whiteDominates = whiteWeight > 0 && whiteWeight >= totalColorWeight * WHITE_DOMINANCE_RATIO;
|
|
260
|
+
if (whiteDominates) {
|
|
261
|
+
return EDGE_WHITE_FALLBACK;
|
|
262
|
+
}
|
|
263
|
+
const [r, g, b] = dominantColor.split(",").map(Number);
|
|
264
|
+
return rgbToHex(r, g, b);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// src/server/optimization/edgeColorExtractor.ts
|
|
268
|
+
async function extractEdgeColorsFromBuffer(buffer) {
|
|
269
|
+
try {
|
|
270
|
+
const sample = await sharp2(buffer).resize(400, 400, {
|
|
271
|
+
fit: "inside",
|
|
272
|
+
kernel: "lanczos3",
|
|
273
|
+
withoutEnlargement: true
|
|
274
|
+
}).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
275
|
+
return computeEdgeColorSet(
|
|
276
|
+
sample.data,
|
|
277
|
+
sample.info.width,
|
|
278
|
+
sample.info.height
|
|
279
|
+
);
|
|
280
|
+
} catch {
|
|
281
|
+
try {
|
|
282
|
+
const fallback = await extractEnhancedColorFromBuffer(buffer, "edge");
|
|
283
|
+
return {
|
|
284
|
+
top: fallback,
|
|
285
|
+
right: fallback,
|
|
286
|
+
bottom: fallback,
|
|
287
|
+
left: fallback
|
|
288
|
+
};
|
|
289
|
+
} catch {
|
|
290
|
+
return {
|
|
291
|
+
top: EDGE_COLOR_FALLBACK,
|
|
292
|
+
right: EDGE_COLOR_FALLBACK,
|
|
293
|
+
bottom: EDGE_COLOR_FALLBACK,
|
|
294
|
+
left: EDGE_COLOR_FALLBACK
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
function computeEdgeColorSet(data, width, height) {
|
|
300
|
+
const thicknessX = Math.max(2, Math.floor(width * 0.12));
|
|
301
|
+
const thicknessY = Math.max(2, Math.floor(height * 0.12));
|
|
302
|
+
const bucketSize = 16;
|
|
303
|
+
const edgeMaps = {
|
|
304
|
+
top: /* @__PURE__ */ new Map(),
|
|
305
|
+
right: /* @__PURE__ */ new Map(),
|
|
306
|
+
bottom: /* @__PURE__ */ new Map(),
|
|
307
|
+
left: /* @__PURE__ */ new Map()
|
|
308
|
+
};
|
|
309
|
+
const whiteEdgeStrength = {
|
|
310
|
+
top: 0,
|
|
311
|
+
right: 0,
|
|
312
|
+
bottom: 0,
|
|
313
|
+
left: 0
|
|
314
|
+
};
|
|
315
|
+
const accumulateEdgeSample = (edge, weight, key, isWhiteSample) => {
|
|
316
|
+
if (weight <= 0) return;
|
|
317
|
+
if (isWhiteSample) {
|
|
318
|
+
whiteEdgeStrength[edge] += weight;
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
edgeMaps[edge].set(key, (edgeMaps[edge].get(key) || 0) + weight);
|
|
322
|
+
};
|
|
323
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
324
|
+
const r = data[i];
|
|
325
|
+
const g = data[i + 1];
|
|
326
|
+
const b = data[i + 2];
|
|
327
|
+
const alpha = data[i + 3];
|
|
328
|
+
if (alpha !== void 0 && alpha < 8) continue;
|
|
329
|
+
const brightness = (r + g + b) / 3;
|
|
330
|
+
if (brightness < 12) continue;
|
|
331
|
+
const isNearWhite = r >= 245 && g >= 245 && b >= 245;
|
|
332
|
+
const pixelIndex = i / 4;
|
|
333
|
+
const x = pixelIndex % width;
|
|
334
|
+
const y = Math.floor(pixelIndex / width);
|
|
335
|
+
const bucketR = Math.floor(r / bucketSize) * bucketSize;
|
|
336
|
+
const bucketG = Math.floor(g / bucketSize) * bucketSize;
|
|
337
|
+
const bucketB = Math.floor(b / bucketSize) * bucketSize;
|
|
338
|
+
const key = `${bucketR},${bucketG},${bucketB}`;
|
|
339
|
+
const topWeight = y < thicknessY ? 1 + (thicknessY - y) / thicknessY : 0;
|
|
340
|
+
if (topWeight > 0) {
|
|
341
|
+
accumulateEdgeSample("top", topWeight, key, isNearWhite);
|
|
342
|
+
}
|
|
343
|
+
const bottomWeight = y >= height - thicknessY ? 1 + (y - (height - thicknessY)) / thicknessY : 0;
|
|
344
|
+
if (bottomWeight > 0) {
|
|
345
|
+
accumulateEdgeSample("bottom", bottomWeight, key, isNearWhite);
|
|
346
|
+
}
|
|
347
|
+
const leftWeight = x < thicknessX ? 1 + (thicknessX - x) / thicknessX : 0;
|
|
348
|
+
if (leftWeight > 0) {
|
|
349
|
+
accumulateEdgeSample("left", leftWeight, key, isNearWhite);
|
|
350
|
+
}
|
|
351
|
+
const rightWeight = x >= width - thicknessX ? 1 + (x - (width - thicknessX)) / thicknessX : 0;
|
|
352
|
+
if (rightWeight > 0) {
|
|
353
|
+
accumulateEdgeSample("right", rightWeight, key, isNearWhite);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return {
|
|
357
|
+
top: resolveEdgeColor(edgeMaps.top, whiteEdgeStrength.top),
|
|
358
|
+
right: resolveEdgeColor(edgeMaps.right, whiteEdgeStrength.right),
|
|
359
|
+
bottom: resolveEdgeColor(edgeMaps.bottom, whiteEdgeStrength.bottom),
|
|
360
|
+
left: resolveEdgeColor(edgeMaps.left, whiteEdgeStrength.left)
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
function resolveEdgeColor(map, whiteStrength) {
|
|
364
|
+
if (!map.size) {
|
|
365
|
+
return whiteStrength > 0 ? EDGE_WHITE_FALLBACK : EDGE_COLOR_FALLBACK;
|
|
366
|
+
}
|
|
367
|
+
let dominantColor = "";
|
|
368
|
+
let maxCount = 0;
|
|
369
|
+
let totalColorWeight = 0;
|
|
370
|
+
map.forEach((count, color) => {
|
|
371
|
+
totalColorWeight += count;
|
|
372
|
+
if (count > maxCount) {
|
|
373
|
+
maxCount = count;
|
|
374
|
+
dominantColor = color;
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
if (!dominantColor) {
|
|
378
|
+
return whiteStrength > 0 ? EDGE_WHITE_FALLBACK : EDGE_COLOR_FALLBACK;
|
|
379
|
+
}
|
|
380
|
+
const whiteDominates = whiteStrength > 0 && (totalColorWeight === 0 || whiteStrength >= totalColorWeight * WHITE_DOMINANCE_RATIO);
|
|
381
|
+
if (whiteDominates) {
|
|
382
|
+
return EDGE_WHITE_FALLBACK;
|
|
383
|
+
}
|
|
384
|
+
if (dominantColor.startsWith("#")) {
|
|
385
|
+
return dominantColor.toUpperCase();
|
|
386
|
+
}
|
|
387
|
+
const [r, g, b] = dominantColor.split(",").map(Number);
|
|
388
|
+
return rgbToHex(r, g, b);
|
|
389
|
+
}
|
|
390
|
+
function blendEdgeColors(edgeColors) {
|
|
391
|
+
const colors = Object.values(edgeColors);
|
|
392
|
+
if (!colors.length) {
|
|
393
|
+
return EDGE_COLOR_FALLBACK;
|
|
394
|
+
}
|
|
395
|
+
const totals = colors.reduce(
|
|
396
|
+
(acc, hex) => {
|
|
397
|
+
const { r, g, b } = hexToRgb(hex || EDGE_COLOR_FALLBACK);
|
|
398
|
+
acc.r += r;
|
|
399
|
+
acc.g += g;
|
|
400
|
+
acc.b += b;
|
|
401
|
+
return acc;
|
|
402
|
+
},
|
|
403
|
+
{ r: 0, g: 0, b: 0 }
|
|
404
|
+
);
|
|
405
|
+
const count = colors.length;
|
|
406
|
+
return rgbToHex(totals.r / count, totals.g / count, totals.b / count);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// src/server/optimization/blurGenerator.ts
|
|
410
|
+
async function encodeBlurHashString(buffer, componentX = 4, componentY = 3) {
|
|
411
|
+
const { data, info } = await sharp3(buffer).resize(32, 32, { fit: "inside" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
412
|
+
const clampedX = Math.max(1, Math.min(Math.round(componentX), info.width, 9));
|
|
413
|
+
const clampedY = Math.max(1, Math.min(Math.round(componentY), info.height, 9));
|
|
414
|
+
return encodeBlurHash(
|
|
415
|
+
new Uint8ClampedArray(data),
|
|
416
|
+
info.width,
|
|
417
|
+
info.height,
|
|
418
|
+
clampedX,
|
|
419
|
+
clampedY
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
async function generateBlurPlaceholder(buffer, options = {}) {
|
|
423
|
+
const targetWidth = options.width ?? BLUR_PLACEHOLDER_WIDTH;
|
|
424
|
+
const fallbackWidth = options.fallbackWidth ?? BLUR_PLACEHOLDER_FALLBACK_WIDTH;
|
|
425
|
+
const quality = options.quality ?? BLUR_PLACEHOLDER_QUALITY;
|
|
426
|
+
const maxChars = options.maxDataUrlChars ?? BLUR_PLACEHOLDER_MAX_CHARS;
|
|
427
|
+
try {
|
|
428
|
+
let processBuffer = buffer;
|
|
429
|
+
const isSvg = /<svg[\s>]/i.test(buffer.toString("utf8", 0, 1024));
|
|
430
|
+
if (isSvg) {
|
|
431
|
+
const normalizedSvg = buffer.toString("utf8").replace(/<svg\b/gi, "<svg").replace(/<\/svg\s*>/gi, "</svg>");
|
|
432
|
+
processBuffer = await sharp3(Buffer.from(normalizedSvg, "utf8")).resize(targetWidth, targetWidth, { fit: "inside" }).png().toBuffer();
|
|
433
|
+
}
|
|
434
|
+
const buildPlaceholder = (width, q) => lqip(processBuffer, {
|
|
435
|
+
outputFormat: "webp",
|
|
436
|
+
resize: width,
|
|
437
|
+
outputOptions: { quality: q, effort: 6, smartSubsample: true }
|
|
438
|
+
});
|
|
439
|
+
let result = await buildPlaceholder(targetWidth, quality);
|
|
440
|
+
if (result.metadata.dataURIBase64.length > maxChars) {
|
|
441
|
+
result = await buildPlaceholder(fallbackWidth, quality);
|
|
442
|
+
}
|
|
443
|
+
const dataURL = result.metadata.dataURIBase64;
|
|
444
|
+
const edgeColors = await extractEdgeColorsFromBuffer(processBuffer);
|
|
445
|
+
const color = blendEdgeColors(edgeColors);
|
|
446
|
+
let blurHash;
|
|
447
|
+
if (options.includeBlurHash) {
|
|
448
|
+
try {
|
|
449
|
+
blurHash = await encodeBlurHashString(processBuffer);
|
|
450
|
+
} catch {
|
|
451
|
+
blurHash = void 0;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return {
|
|
455
|
+
dataURL,
|
|
456
|
+
color,
|
|
457
|
+
...blurHash && { blurHash }
|
|
458
|
+
};
|
|
459
|
+
} catch {
|
|
460
|
+
return generateFallbackBlur(buffer, options.includeBlurHash);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
async function generateFallbackBlur(buffer, includeBlurHash) {
|
|
464
|
+
const tinyBuffer = await sharp3(buffer).resize(64, 64, { fit: "cover", kernel: "lanczos3" }).blur(1).webp({
|
|
465
|
+
quality: 75,
|
|
466
|
+
effort: 6,
|
|
467
|
+
smartSubsample: false
|
|
468
|
+
}).toBuffer();
|
|
469
|
+
const dataURL = `data:image/webp;base64,${tinyBuffer.toString("base64")}`;
|
|
470
|
+
const edgeColors = await extractEdgeColorsFromBuffer(buffer);
|
|
471
|
+
const color = blendEdgeColors(edgeColors);
|
|
472
|
+
let blurHash;
|
|
473
|
+
if (includeBlurHash) {
|
|
474
|
+
try {
|
|
475
|
+
blurHash = await encodeBlurHashString(tinyBuffer);
|
|
476
|
+
} catch {
|
|
477
|
+
blurHash = void 0;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return { dataURL, color, ...blurHash && { blurHash } };
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// src/server/semaphore.ts
|
|
484
|
+
var AsyncSemaphore = class {
|
|
485
|
+
constructor(concurrency) {
|
|
486
|
+
this.concurrency = concurrency;
|
|
487
|
+
if (concurrency < 1) {
|
|
488
|
+
throw new Error("AsyncSemaphore concurrency must be >= 1");
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
concurrency;
|
|
492
|
+
activeCount = 0;
|
|
493
|
+
queue = [];
|
|
494
|
+
async run(task) {
|
|
495
|
+
await this.acquire();
|
|
496
|
+
try {
|
|
497
|
+
return await task();
|
|
498
|
+
} finally {
|
|
499
|
+
this.release();
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
acquire() {
|
|
503
|
+
if (this.activeCount < this.concurrency) {
|
|
504
|
+
this.activeCount += 1;
|
|
505
|
+
return Promise.resolve();
|
|
506
|
+
}
|
|
507
|
+
return new Promise((resolve) => {
|
|
508
|
+
this.queue.push(() => {
|
|
509
|
+
this.activeCount += 1;
|
|
510
|
+
resolve();
|
|
511
|
+
});
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
release() {
|
|
515
|
+
this.activeCount = Math.max(0, this.activeCount - 1);
|
|
516
|
+
const next = this.queue.shift();
|
|
517
|
+
next?.();
|
|
518
|
+
}
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
// src/cli/scanner.ts
|
|
522
|
+
import fs from "fs/promises";
|
|
523
|
+
import path from "path";
|
|
524
|
+
var SUPPORTED_IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
525
|
+
".jpg",
|
|
526
|
+
".jpeg",
|
|
527
|
+
".png",
|
|
528
|
+
".webp",
|
|
529
|
+
".avif",
|
|
530
|
+
".svg"
|
|
531
|
+
]);
|
|
532
|
+
var IGNORED_DIRECTORIES = /* @__PURE__ */ new Set(["node_modules", ".git", "dist"]);
|
|
533
|
+
async function scanImageDirectory(imagesDir) {
|
|
534
|
+
const rootDir = path.resolve(imagesDir);
|
|
535
|
+
const rootStats = await fs.stat(rootDir);
|
|
536
|
+
if (!rootStats.isDirectory()) {
|
|
537
|
+
throw new Error(`Expected images path to be a directory: ${rootDir}`);
|
|
538
|
+
}
|
|
539
|
+
const results = [];
|
|
540
|
+
async function walk(currentDir) {
|
|
541
|
+
const entries = await fs.readdir(currentDir, { withFileTypes: true });
|
|
542
|
+
for (const entry of entries) {
|
|
543
|
+
const fullPath = path.join(currentDir, entry.name);
|
|
544
|
+
if (entry.isDirectory()) {
|
|
545
|
+
if (IGNORED_DIRECTORIES.has(entry.name)) {
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
await walk(fullPath);
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
if (!entry.isFile()) {
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
555
|
+
if (!SUPPORTED_IMAGE_EXTENSIONS.has(ext)) {
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
const fileStats = await fs.stat(fullPath);
|
|
559
|
+
const relPath = path.relative(rootDir, fullPath).split(path.sep).join("/").replace(/\\/g, "/");
|
|
560
|
+
const relativeKey = relPath.startsWith("/") ? relPath : `/${relPath}`;
|
|
561
|
+
results.push({
|
|
562
|
+
absolutePath: fullPath,
|
|
563
|
+
relativeKey,
|
|
564
|
+
size: fileStats.size,
|
|
565
|
+
mtimeMs: fileStats.mtimeMs
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
await walk(rootDir);
|
|
570
|
+
results.sort((a, b) => a.relativeKey.localeCompare(b.relativeKey));
|
|
571
|
+
return results;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// src/cli/generator.ts
|
|
575
|
+
var FALLBACK_BLURHASH = "LEHV6nWB2yk8pyo0adR*.7kCMdnj";
|
|
576
|
+
var MANIFEST_VERSION = "2.0";
|
|
577
|
+
async function loadExistingHashes(manifestPath) {
|
|
578
|
+
try {
|
|
579
|
+
const parsed = JSON.parse(await fs2.readFile(manifestPath, "utf8"));
|
|
580
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && "hashes" in parsed && parsed.hashes && typeof parsed.hashes === "object" && !Array.isArray(parsed.hashes)) {
|
|
581
|
+
return parsed.hashes;
|
|
582
|
+
}
|
|
583
|
+
return {};
|
|
584
|
+
} catch {
|
|
585
|
+
return {};
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
function canReuseCachedEntry(entry, file, includeMedium) {
|
|
589
|
+
if (!entry || typeof entry !== "object") return false;
|
|
590
|
+
if (!entry.hash || !entry.color || !entry.variants?.small) return false;
|
|
591
|
+
if (entry.metadata) {
|
|
592
|
+
if (entry.metadata.size && entry.metadata.size !== String(file.size)) {
|
|
593
|
+
return false;
|
|
594
|
+
}
|
|
595
|
+
if (entry.metadata.mtimeMs && entry.metadata.mtimeMs !== String(Math.round(file.mtimeMs))) {
|
|
596
|
+
return false;
|
|
597
|
+
}
|
|
598
|
+
if (includeMedium && !entry.variants.medium) return false;
|
|
599
|
+
}
|
|
600
|
+
return true;
|
|
601
|
+
}
|
|
602
|
+
async function resolveImageDimensions(buffer, isSvg) {
|
|
603
|
+
const metadata = await sharp4(buffer).metadata();
|
|
604
|
+
let { width, height } = metadata;
|
|
605
|
+
if ((!width || !height) && isSvg) {
|
|
606
|
+
const match = buffer.toString("utf8").match(/viewBox\s*=\s*["']\s*([\d.-]+)\s+([\d.-]+)\s+([\d.-]+)\s+([\d.-]+)\s*["']/i);
|
|
607
|
+
if (match) {
|
|
608
|
+
width = Math.round(Number.parseFloat(match[3]));
|
|
609
|
+
height = Math.round(Number.parseFloat(match[4]));
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
return { width, height };
|
|
613
|
+
}
|
|
614
|
+
async function generateBlurManifest(imagesDir, manifestFile, options = {}) {
|
|
615
|
+
const outputPath = path2.resolve(manifestFile);
|
|
616
|
+
const concurrency = Math.max(1, Math.floor(options.concurrency ?? 4));
|
|
617
|
+
const includeMedium = options.includeMedium ?? true;
|
|
618
|
+
const force = Boolean(options.force);
|
|
619
|
+
const files = await scanImageDirectory(imagesDir);
|
|
620
|
+
const existingHashes = force ? {} : await loadExistingHashes(outputPath);
|
|
621
|
+
const semaphore = new AsyncSemaphore(concurrency);
|
|
622
|
+
const generatedEntries = /* @__PURE__ */ new Map();
|
|
623
|
+
let processed = 0;
|
|
624
|
+
let cached = 0;
|
|
625
|
+
let failed = 0;
|
|
626
|
+
let done = 0;
|
|
627
|
+
const total = files.length;
|
|
628
|
+
await Promise.all(
|
|
629
|
+
files.map(
|
|
630
|
+
(file) => semaphore.run(async () => {
|
|
631
|
+
try {
|
|
632
|
+
const prior = force ? void 0 : existingHashes[file.relativeKey];
|
|
633
|
+
if (canReuseCachedEntry(prior, file, includeMedium)) {
|
|
634
|
+
generatedEntries.set(file.relativeKey, prior);
|
|
635
|
+
cached += 1;
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
const buffer = await fs2.readFile(file.absolutePath);
|
|
639
|
+
const isSvg = file.relativeKey.toLowerCase().endsWith(".svg");
|
|
640
|
+
const { width, height } = await resolveImageDimensions(buffer, isSvg);
|
|
641
|
+
const small = await generateBlurPlaceholder(buffer, {
|
|
642
|
+
width: 64,
|
|
643
|
+
fallbackWidth: 48,
|
|
644
|
+
includeBlurHash: true
|
|
645
|
+
});
|
|
646
|
+
const medium = includeMedium ? await generateBlurPlaceholder(buffer, {
|
|
647
|
+
width: 128,
|
|
648
|
+
fallbackWidth: 96,
|
|
649
|
+
includeBlurHash: false
|
|
650
|
+
}) : void 0;
|
|
651
|
+
const hash = small.blurHash || await encodeBlurHashString(buffer).catch(() => FALLBACK_BLURHASH);
|
|
652
|
+
generatedEntries.set(file.relativeKey, {
|
|
653
|
+
hash,
|
|
654
|
+
variants: {
|
|
655
|
+
small: small.dataURL,
|
|
656
|
+
...medium ? { medium: medium.dataURL } : {}
|
|
657
|
+
},
|
|
658
|
+
color: small.color,
|
|
659
|
+
...width ? { width } : {},
|
|
660
|
+
...height ? { height } : {},
|
|
661
|
+
metadata: {
|
|
662
|
+
size: String(file.size),
|
|
663
|
+
mtimeMs: String(Math.round(file.mtimeMs))
|
|
664
|
+
}
|
|
665
|
+
});
|
|
666
|
+
processed += 1;
|
|
667
|
+
} catch {
|
|
668
|
+
failed += 1;
|
|
669
|
+
} finally {
|
|
670
|
+
done += 1;
|
|
671
|
+
options.onProgress?.(done, total, file.relativeKey);
|
|
672
|
+
}
|
|
673
|
+
})
|
|
674
|
+
)
|
|
675
|
+
);
|
|
676
|
+
const orderedHashes = {};
|
|
677
|
+
for (const file of files) {
|
|
678
|
+
const entry = generatedEntries.get(file.relativeKey);
|
|
679
|
+
if (entry) orderedHashes[file.relativeKey] = entry;
|
|
680
|
+
}
|
|
681
|
+
const manifest = {
|
|
682
|
+
version: MANIFEST_VERSION,
|
|
683
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
684
|
+
totalImages: Object.keys(orderedHashes).length,
|
|
685
|
+
processedImages: processed,
|
|
686
|
+
cachedImages: cached,
|
|
687
|
+
failedImages: failed,
|
|
688
|
+
hashes: orderedHashes
|
|
689
|
+
};
|
|
690
|
+
await fs2.mkdir(path2.dirname(outputPath), { recursive: true });
|
|
691
|
+
await fs2.writeFile(outputPath, `${JSON.stringify(manifest, null, 2)}
|
|
692
|
+
`, "utf8");
|
|
693
|
+
return { manifest, processed, cached, failed, outputPath };
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// src/cli/validation.ts
|
|
697
|
+
import fs3 from "fs/promises";
|
|
698
|
+
import path3 from "path";
|
|
699
|
+
var HEX_COLOR_RE = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
|
700
|
+
function isPlainObject(value) {
|
|
701
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
702
|
+
}
|
|
703
|
+
function toNormalizedKey(key) {
|
|
704
|
+
return normalizeImagePath(key, { stripPrefixes: [] }).normalizedPath;
|
|
705
|
+
}
|
|
706
|
+
function failValidation(error) {
|
|
707
|
+
return { valid: false, totalEntries: 0, errors: [error], warnings: [] };
|
|
708
|
+
}
|
|
709
|
+
function isDataImageUrl(value) {
|
|
710
|
+
return typeof value === "string" && value.startsWith("data:image/");
|
|
711
|
+
}
|
|
712
|
+
function computeKeyDiff(files, hashes) {
|
|
713
|
+
const diskKeys = new Set(files.map((f) => f.relativeKey));
|
|
714
|
+
const manifestKeys = Object.keys(hashes);
|
|
715
|
+
const normalizedSet = new Set(manifestKeys.map(toNormalizedKey));
|
|
716
|
+
const missingKeys = files.map((f) => f.relativeKey).filter((k) => !(k in hashes) && !normalizedSet.has(k));
|
|
717
|
+
const orphanedKeys = manifestKeys.filter(
|
|
718
|
+
(k) => !diskKeys.has(k) && !diskKeys.has(toNormalizedKey(k))
|
|
719
|
+
);
|
|
720
|
+
return { missingKeys, orphanedKeys };
|
|
721
|
+
}
|
|
722
|
+
async function readManifestSafe(manifestFile) {
|
|
723
|
+
try {
|
|
724
|
+
const parsed = JSON.parse(await fs3.readFile(path3.resolve(manifestFile), "utf8"));
|
|
725
|
+
if (isPlainObject(parsed) && isPlainObject(parsed.hashes)) {
|
|
726
|
+
return {
|
|
727
|
+
exists: true,
|
|
728
|
+
manifest: parsed,
|
|
729
|
+
hashes: parsed.hashes
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
return { exists: true, hashes: {} };
|
|
733
|
+
} catch (err) {
|
|
734
|
+
return { exists: err.code !== "ENOENT", hashes: {} };
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
function validateEntry(key, value, errors, warnings) {
|
|
738
|
+
if (!key.startsWith("/")) warnings.push(`Entry key "${key}" should start with "/"`);
|
|
739
|
+
if (!isPlainObject(value)) {
|
|
740
|
+
errors.push(`Entry "${key}" must be an object`);
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
const entry = value;
|
|
744
|
+
if (typeof entry.hash !== "string" || entry.hash.trim().length < 6) {
|
|
745
|
+
errors.push(`Entry "${key}" has missing or invalid BlurHash "hash"`);
|
|
746
|
+
}
|
|
747
|
+
if (typeof entry.color !== "string" || !HEX_COLOR_RE.test(entry.color)) {
|
|
748
|
+
errors.push(`Entry "${key}" has missing or invalid hex "color"`);
|
|
749
|
+
}
|
|
750
|
+
if (!isPlainObject(entry.variants)) {
|
|
751
|
+
errors.push(`Entry "${key}" is missing "variants" object`);
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
if (!isDataImageUrl(entry.variants.small)) {
|
|
755
|
+
errors.push(`Entry "${key}" is missing valid "variants.small" data URL`);
|
|
756
|
+
}
|
|
757
|
+
if (entry.variants.medium !== void 0 && !isDataImageUrl(entry.variants.medium)) {
|
|
758
|
+
errors.push(`Entry "${key}" has invalid "variants.medium" data URL`);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
async function validateBlurHashFile(manifestFile) {
|
|
762
|
+
const resolvedPath = path3.resolve(manifestFile);
|
|
763
|
+
let raw;
|
|
764
|
+
try {
|
|
765
|
+
raw = await fs3.readFile(resolvedPath, "utf8");
|
|
766
|
+
} catch {
|
|
767
|
+
return failValidation(`Manifest file not found or unreadable: ${resolvedPath}`);
|
|
768
|
+
}
|
|
769
|
+
let parsed;
|
|
770
|
+
try {
|
|
771
|
+
parsed = JSON.parse(raw);
|
|
772
|
+
} catch (err) {
|
|
773
|
+
return failValidation(`Invalid JSON in manifest: ${err.message}`);
|
|
774
|
+
}
|
|
775
|
+
if (!isPlainObject(parsed)) return failValidation("Manifest root must be a JSON object");
|
|
776
|
+
if (!isPlainObject(parsed.hashes)) return failValidation('Manifest is missing a valid "hashes" object');
|
|
777
|
+
const errors = [];
|
|
778
|
+
const warnings = [];
|
|
779
|
+
if (typeof parsed.version !== "string" || !parsed.version.trim()) {
|
|
780
|
+
warnings.push('Manifest is missing a "version" string');
|
|
781
|
+
}
|
|
782
|
+
if (typeof parsed.generatedAt !== "string" || Number.isNaN(Date.parse(parsed.generatedAt))) {
|
|
783
|
+
warnings.push('Manifest has missing or invalid "generatedAt" timestamp');
|
|
784
|
+
}
|
|
785
|
+
const entries = Object.entries(parsed.hashes);
|
|
786
|
+
if (typeof parsed.totalImages === "number" && parsed.totalImages !== entries.length) {
|
|
787
|
+
warnings.push(`Manifest totalImages (${parsed.totalImages}) differs from count (${entries.length})`);
|
|
788
|
+
}
|
|
789
|
+
if (entries.length === 0) warnings.push("Manifest contains 0 image entries");
|
|
790
|
+
for (const [key, value] of entries) validateEntry(key, value, errors, warnings);
|
|
791
|
+
return { valid: errors.length === 0, totalEntries: entries.length, errors, warnings };
|
|
792
|
+
}
|
|
793
|
+
async function autoSyncCheck(imagesDir, manifestFile) {
|
|
794
|
+
const [files, loaded] = await Promise.all([scanImageDirectory(imagesDir), readManifestSafe(manifestFile)]);
|
|
795
|
+
const { missingKeys, orphanedKeys } = computeKeyDiff(files, loaded.hashes);
|
|
796
|
+
const inSync = loaded.exists && missingKeys.length === 0 && orphanedKeys.length === 0;
|
|
797
|
+
return { inSync, totalFiles: files.length, totalEntries: Object.keys(loaded.hashes).length, missingKeys, orphanedKeys };
|
|
798
|
+
}
|
|
799
|
+
async function findMissingBlurHashes(imagesDir, manifestFile) {
|
|
800
|
+
return (await autoSyncCheck(imagesDir, manifestFile)).missingKeys;
|
|
801
|
+
}
|
|
802
|
+
async function cleanupOrphanedHashes(imagesDir, manifestFile, options) {
|
|
803
|
+
const dryRun = Boolean(options?.dryRun);
|
|
804
|
+
const resolvedPath = path3.resolve(manifestFile);
|
|
805
|
+
const [files, loaded] = await Promise.all([scanImageDirectory(imagesDir), readManifestSafe(resolvedPath)]);
|
|
806
|
+
if (!loaded.exists || !loaded.manifest) return { removedCount: 0, removedKeys: [], dryRun };
|
|
807
|
+
const { orphanedKeys } = computeKeyDiff(files, loaded.hashes);
|
|
808
|
+
if (!dryRun && orphanedKeys.length > 0) {
|
|
809
|
+
const orphanSet = new Set(orphanedKeys);
|
|
810
|
+
const hashes = Object.fromEntries(Object.entries(loaded.hashes).filter(([k]) => !orphanSet.has(k)));
|
|
811
|
+
const updated = { ...loaded.manifest, generatedAt: (/* @__PURE__ */ new Date()).toISOString(), totalImages: Object.keys(hashes).length, hashes };
|
|
812
|
+
await fs3.writeFile(resolvedPath, `${JSON.stringify(updated, null, 2)}
|
|
813
|
+
`, "utf8");
|
|
814
|
+
}
|
|
815
|
+
return { removedCount: orphanedKeys.length, removedKeys: orphanedKeys, dryRun };
|
|
816
|
+
}
|
|
817
|
+
async function generateHealthReport(imagesDir, manifestFile) {
|
|
818
|
+
const [validation, loaded] = await Promise.all([validateBlurHashFile(manifestFile), readManifestSafe(manifestFile)]);
|
|
819
|
+
const errors = [...validation.errors];
|
|
820
|
+
const warnings = [...validation.warnings];
|
|
821
|
+
let files = [];
|
|
822
|
+
try {
|
|
823
|
+
files = await scanImageDirectory(imagesDir);
|
|
824
|
+
} catch (err) {
|
|
825
|
+
errors.push(`Failed to scan "${path3.resolve(imagesDir)}": ${err.message}`);
|
|
826
|
+
}
|
|
827
|
+
const { missingKeys, orphanedKeys } = computeKeyDiff(files, loaded.hashes);
|
|
828
|
+
if (missingKeys.length > 0) warnings.push(`${missingKeys.length} missing image(s)`);
|
|
829
|
+
if (orphanedKeys.length > 0) warnings.push(`${orphanedKeys.length} orphaned entry(ies)`);
|
|
830
|
+
const totalImagesOnDisk = files.length;
|
|
831
|
+
const covered = Math.max(0, totalImagesOnDisk - missingKeys.length);
|
|
832
|
+
const coveragePercent = totalImagesOnDisk === 0 ? validation.valid ? 100 : 0 : Number((covered / totalImagesOnDisk * 100).toFixed(2));
|
|
833
|
+
return {
|
|
834
|
+
validManifest: validation.valid,
|
|
835
|
+
totalImagesOnDisk,
|
|
836
|
+
totalManifestEntries: validation.totalEntries,
|
|
837
|
+
coveragePercent,
|
|
838
|
+
missingKeys,
|
|
839
|
+
orphanedKeys,
|
|
840
|
+
errors,
|
|
841
|
+
warnings
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// src/cli/commands.ts
|
|
846
|
+
var DEFAULT_IMAGES_DIR = "public/images";
|
|
847
|
+
var DEFAULT_MANIFEST_FILE = "src/blur-hashes.json";
|
|
848
|
+
function findManifestEntry(hashes, imageKey) {
|
|
849
|
+
const candidates = [
|
|
850
|
+
imageKey,
|
|
851
|
+
normalizeImagePath(imageKey).normalizedPath,
|
|
852
|
+
normalizeImagePath(imageKey, { stripPrefixes: [] }).normalizedPath
|
|
853
|
+
];
|
|
854
|
+
for (const candidate of candidates) {
|
|
855
|
+
if (hashes[candidate]) return { key: candidate, entry: hashes[candidate] };
|
|
856
|
+
const noSlash = candidate.replace(/^\/+/, "");
|
|
857
|
+
if (hashes[noSlash]) return { key: noSlash, entry: hashes[noSlash] };
|
|
858
|
+
}
|
|
859
|
+
return null;
|
|
860
|
+
}
|
|
861
|
+
function createCliProgram() {
|
|
862
|
+
const program = new Command();
|
|
863
|
+
program.name("img-upload-kit").description("LQIP, BlurHash, and dominant color manifest CLI tooling").version("1.1.1");
|
|
864
|
+
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(
|
|
865
|
+
async (opts) => {
|
|
866
|
+
const concurrency = Math.max(1, Number.parseInt(opts.concurrency, 10) || 4);
|
|
867
|
+
const result = await generateBlurManifest(opts.dir, opts.out, {
|
|
868
|
+
concurrency,
|
|
869
|
+
force: Boolean(opts.force),
|
|
870
|
+
includeMedium: opts.medium
|
|
871
|
+
});
|
|
872
|
+
console.log(
|
|
873
|
+
`Generated ${result.outputPath} (total: ${result.manifest.totalImages}, processed: ${result.processed}, cached: ${result.cached}, failed: ${result.failed})`
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
);
|
|
877
|
+
program.command("validate").description("Validate a blur manifest JSON file").option("-f, --file <file>", "Manifest JSON path", DEFAULT_MANIFEST_FILE).action(async (opts) => {
|
|
878
|
+
const result = await validateBlurHashFile(opts.file);
|
|
879
|
+
for (const warning of result.warnings) console.warn(`Warning: ${warning}`);
|
|
880
|
+
if (!result.valid) {
|
|
881
|
+
for (const error of result.errors) console.error(`Error: ${error}`);
|
|
882
|
+
process.exitCode = 1;
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
console.log(`Manifest is valid (${result.totalEntries} entries).`);
|
|
886
|
+
});
|
|
887
|
+
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 blur manifest entries automatically", false).action(async (opts) => {
|
|
888
|
+
const sync = await autoSyncCheck(opts.dir, opts.file);
|
|
889
|
+
if (sync.inSync) {
|
|
890
|
+
console.log(`In sync (${sync.totalEntries} entries).`);
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
console.log(
|
|
894
|
+
`Out of sync: ${sync.missingKeys.length} missing, ${sync.orphanedKeys.length} orphaned.`
|
|
895
|
+
);
|
|
896
|
+
if (opts.fix && sync.missingKeys.length > 0) {
|
|
897
|
+
const result = await generateBlurManifest(opts.dir, opts.file);
|
|
898
|
+
console.log(
|
|
899
|
+
`Synced manifest (processed: ${result.processed}, cached: ${result.cached}).`
|
|
900
|
+
);
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
if (!opts.fix) process.exitCode = 1;
|
|
904
|
+
});
|
|
905
|
+
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) => {
|
|
906
|
+
const result = await cleanupOrphanedHashes(opts.dir, opts.file, {
|
|
907
|
+
dryRun: Boolean(opts.dryRun)
|
|
908
|
+
});
|
|
909
|
+
const prefix = result.dryRun ? "[Dry Run] Would remove" : "Removed";
|
|
910
|
+
console.log(`${prefix} ${result.removedCount} orphaned entries.`);
|
|
911
|
+
});
|
|
912
|
+
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) => {
|
|
913
|
+
const report = await generateHealthReport(opts.dir, opts.file);
|
|
914
|
+
console.log(
|
|
915
|
+
`Health: valid=${report.validManifest}, coverage=${report.coveragePercent}% (${report.totalManifestEntries} entries / ${report.totalImagesOnDisk} disk images)`
|
|
916
|
+
);
|
|
917
|
+
for (const warning of report.warnings) console.warn(`Warning: ${warning}`);
|
|
918
|
+
for (const error of report.errors) console.error(`Error: ${error}`);
|
|
919
|
+
if (!report.validManifest || report.errors.length > 0) {
|
|
920
|
+
process.exitCode = 1;
|
|
921
|
+
}
|
|
922
|
+
});
|
|
923
|
+
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) => {
|
|
924
|
+
const raw = await fs4.readFile(path4.resolve(opts.file), "utf8");
|
|
925
|
+
const parsed = JSON.parse(raw);
|
|
926
|
+
const match = parsed.hashes ? findManifestEntry(parsed.hashes, imageKey) : null;
|
|
927
|
+
if (!match) {
|
|
928
|
+
console.error(`Entry not found for key: ${imageKey}`);
|
|
929
|
+
process.exitCode = 1;
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
console.log(
|
|
933
|
+
JSON.stringify(
|
|
934
|
+
{
|
|
935
|
+
key: match.key,
|
|
936
|
+
hash: match.entry.hash,
|
|
937
|
+
color: match.entry.color,
|
|
938
|
+
width: match.entry.width,
|
|
939
|
+
height: match.entry.height,
|
|
940
|
+
variants: {
|
|
941
|
+
smallChars: match.entry.variants.small.length,
|
|
942
|
+
mediumChars: match.entry.variants.medium?.length ?? 0,
|
|
943
|
+
hasMedium: Boolean(match.entry.variants.medium)
|
|
944
|
+
},
|
|
945
|
+
metadata: match.entry.metadata
|
|
946
|
+
},
|
|
947
|
+
null,
|
|
948
|
+
2
|
|
949
|
+
)
|
|
950
|
+
);
|
|
951
|
+
});
|
|
952
|
+
return program;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// src/cli/index.ts
|
|
956
|
+
var CLI_SUBCOMMANDS = /* @__PURE__ */ new Set([
|
|
957
|
+
"generate",
|
|
958
|
+
"validate",
|
|
959
|
+
"sync",
|
|
960
|
+
"cleanup",
|
|
961
|
+
"health",
|
|
962
|
+
"inspect",
|
|
963
|
+
"help"
|
|
964
|
+
]);
|
|
965
|
+
async function runCli(argv) {
|
|
966
|
+
const program = createCliProgram();
|
|
967
|
+
if (!argv) {
|
|
968
|
+
await program.parseAsync(process.argv);
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
const isUserArgs = argv.length === 0 || CLI_SUBCOMMANDS.has(argv[0]) || argv[0].startsWith("-");
|
|
972
|
+
await program.parseAsync(argv, isUserArgs ? { from: "user" } : void 0);
|
|
973
|
+
}
|
|
974
|
+
function isDirectCliExecution(entryArg) {
|
|
975
|
+
if (!entryArg) return false;
|
|
976
|
+
const normalized = entryArg.replace(/\\/g, "/");
|
|
977
|
+
return normalized === "cli.js" || normalized === "cli.cjs" || normalized.endsWith("/cli.js") || normalized.endsWith("/cli.cjs") || normalized.endsWith("/cli/index.ts") || normalized.endsWith("/bin/img-upload-kit") || normalized.endsWith("/.bin/img-upload-kit") || normalized.endsWith("/bin/image-upload-kit") || normalized.endsWith("/.bin/image-upload-kit") || normalized.endsWith("/bin/blur-cli") || normalized.endsWith("/.bin/blur-cli");
|
|
978
|
+
}
|
|
979
|
+
if (typeof process !== "undefined" && isDirectCliExecution(process.argv?.[1])) {
|
|
980
|
+
void runCli().catch((err) => {
|
|
981
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
982
|
+
process.exitCode = 1;
|
|
983
|
+
});
|
|
984
|
+
}
|
|
985
|
+
export {
|
|
986
|
+
IGNORED_DIRECTORIES,
|
|
987
|
+
SUPPORTED_IMAGE_EXTENSIONS,
|
|
988
|
+
autoSyncCheck,
|
|
989
|
+
cleanupOrphanedHashes,
|
|
990
|
+
createCliProgram,
|
|
991
|
+
findMissingBlurHashes,
|
|
992
|
+
generateBlurManifest,
|
|
993
|
+
generateHealthReport,
|
|
994
|
+
runCli,
|
|
995
|
+
scanImageDirectory,
|
|
996
|
+
validateBlurHashFile
|
|
997
|
+
};
|
|
998
|
+
//# sourceMappingURL=cli.js.map
|