gotodev-image-optimizer 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,705 @@
1
+ // src/vite-plugin.ts
2
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2 } from "fs";
3
+ import { basename, extname, resolve } from "path";
4
+
5
+ // src/core/encoder.ts
6
+ import { readFileSync } from "fs";
7
+ import sharp4 from "sharp";
8
+
9
+ // src/utils/hash.ts
10
+ import { createHash } from "crypto";
11
+ function contentHash(buffer) {
12
+ return createHash("sha384").update(buffer).digest("base64url").slice(0, 16);
13
+ }
14
+ function sriHash(buffer) {
15
+ const hash = createHash("sha384").update(buffer).digest("base64");
16
+ return `sha384-${hash}`;
17
+ }
18
+
19
+ // src/core/analyzer.ts
20
+ import sharp from "sharp";
21
+
22
+ // src/utils/entropy.ts
23
+ function computeEntropy(pixels) {
24
+ const histogram = new Float64Array(256);
25
+ const len = pixels.length;
26
+ for (let i = 0; i < len; i++) {
27
+ const val = pixels[i];
28
+ if (val !== void 0) {
29
+ histogram[val] = (histogram[val] ?? 0) + 1;
30
+ }
31
+ }
32
+ let entropy = 0;
33
+ const lenF = len;
34
+ for (let i = 0; i < 256; i++) {
35
+ const count = histogram[i];
36
+ if (count && count > 0) {
37
+ const p = count / lenF;
38
+ entropy -= p * Math.log2(p);
39
+ }
40
+ }
41
+ return entropy;
42
+ }
43
+ function computeEdgeDensity(pixels, width, height) {
44
+ let edges = 0;
45
+ let total = 0;
46
+ for (let y = 1; y < height - 1; y++) {
47
+ for (let x = 1; x < width - 1; x++) {
48
+ const idx = y * width + x;
49
+ const center = pixels[idx] ?? 0;
50
+ const right = pixels[idx + 1] ?? 0;
51
+ const down = pixels[idx + width] ?? 0;
52
+ const dx = Math.abs(center - right);
53
+ const dy = Math.abs(center - down);
54
+ if (dx > 20 || dy > 20) {
55
+ edges++;
56
+ }
57
+ total++;
58
+ }
59
+ }
60
+ return total > 0 ? edges / total : 0;
61
+ }
62
+ function computeCenterWeight(tileX, tileY, tilesX, tilesY) {
63
+ const cx = (tileX + 0.5) / tilesX - 0.5;
64
+ const cy = (tileY + 0.5) / tilesY - 0.5;
65
+ const dist = Math.sqrt(cx * cx + cy * cy);
66
+ const normalizedDist = Math.min(1, dist / Math.SQRT1_2);
67
+ return 1 - normalizedDist * 0.4;
68
+ }
69
+
70
+ // src/core/analyzer.ts
71
+ function computeSkinRatio(pixels, width, height) {
72
+ let skin = 0;
73
+ const total = width * height;
74
+ for (let i = 0; i < total; i++) {
75
+ const r = pixels[i * 3] ?? 0;
76
+ const g = pixels[i * 3 + 1] ?? 0;
77
+ const b = pixels[i * 3 + 2] ?? 0;
78
+ const cr = (r - 128) * 0.713 + (g - 128) * -0.287 + (b - 128) * -0.426 + 128;
79
+ const cb = (r - 128) * -0.169 + (g - 128) * -0.331 + (b - 128) * 0.5 + 128;
80
+ if (cr >= 133 && cr <= 173 && cb >= 77 && cb <= 127) {
81
+ skin++;
82
+ }
83
+ }
84
+ return total > 0 ? skin / total : 0;
85
+ }
86
+ async function analyzeImage(imagePath, tileSize = 64, detectFaces = false) {
87
+ const metadata = await sharp(imagePath).metadata();
88
+ const width = metadata.width ?? 0;
89
+ const height = metadata.height ?? 0;
90
+ if (width === 0 || height === 0) {
91
+ throw new Error("Could not read image dimensions");
92
+ }
93
+ const tilesX = Math.ceil(width / tileSize);
94
+ const tilesY = Math.ceil(height / tileSize);
95
+ const tiles = [];
96
+ let totalImportance = 0;
97
+ for (let ty = 0; ty < tilesY; ty++) {
98
+ for (let tx = 0; tx < tilesX; tx++) {
99
+ const left = tx * tileSize;
100
+ const top = ty * tileSize;
101
+ const tileW = Math.min(tileSize, width - left);
102
+ const tileH = Math.min(tileSize, height - top);
103
+ const buffer = await sharp(imagePath).extract({ left, top, width: tileW, height: tileH }).raw().toBuffer();
104
+ const raw = new Uint8Array(buffer);
105
+ const pixelCount = tileW * tileH;
106
+ const gray = new Uint8Array(pixelCount);
107
+ for (let i = 0; i < pixelCount; i++) {
108
+ const r = raw[i * 3] ?? 0;
109
+ const g = raw[i * 3 + 1] ?? 0;
110
+ const b = raw[i * 3 + 2] ?? 0;
111
+ gray[i] = Math.round(r * 0.299 + g * 0.587 + b * 0.114);
112
+ }
113
+ const entropy = computeEntropy(gray);
114
+ const edgeDensity = computeEdgeDensity(gray, tileW, tileH);
115
+ const centerWeight = computeCenterWeight(tx, ty, tilesX, tilesY);
116
+ const normalizedEntropy = entropy / 8;
117
+ let importance = Math.min(1, normalizedEntropy * 0.4 + edgeDensity * 0.6);
118
+ if (detectFaces && importance < 0.9) {
119
+ const skinRatio = computeSkinRatio(raw, tileW, tileH);
120
+ if (skinRatio > 0.05) {
121
+ importance = Math.min(1, importance + 0.3);
122
+ }
123
+ }
124
+ tiles.push({
125
+ x: tx,
126
+ y: ty,
127
+ entropy,
128
+ edgeDensity,
129
+ importance,
130
+ centerWeight
131
+ });
132
+ totalImportance += importance;
133
+ }
134
+ }
135
+ const tileQualities = computeTileQualities(tiles);
136
+ return {
137
+ tiles,
138
+ tileQualities,
139
+ overallImportance: tiles.length > 0 ? totalImportance / tiles.length : 0,
140
+ tileSize,
141
+ width,
142
+ height
143
+ };
144
+ }
145
+ function computeTileQualities(tiles) {
146
+ const tileQualities = [];
147
+ for (const tile of tiles) {
148
+ let importance = tile.importance;
149
+ if (tile.edgeDensity > 0.3) {
150
+ const bonus = Math.min(0.2, (tile.edgeDensity - 0.3) * 0.5);
151
+ importance = Math.min(1, importance + bonus);
152
+ }
153
+ if (tile.centerWeight > 0.7) {
154
+ importance = Math.min(1, importance + (tile.centerWeight - 0.7) * 0.3);
155
+ }
156
+ tileQualities.push({
157
+ x: tile.x,
158
+ y: tile.y,
159
+ saliency: importance,
160
+ quality: 0,
161
+ weight: importance ** 2 + 0.1
162
+ });
163
+ }
164
+ return tileQualities;
165
+ }
166
+ function computeTargetQuality(analysis, baseQuality = 80, options) {
167
+ if (analysis.tiles.length === 0) return baseQuality;
168
+ const minQuality = options?.minQuality ?? Math.max(20, baseQuality - 30);
169
+ const maxQuality = options?.maxQuality ?? Math.min(95, baseQuality + 10);
170
+ const tileQualities = analysis.tileQualities;
171
+ const weighted = tileQualities.map((tq) => ({
172
+ quality: minQuality + tq.saliency * (maxQuality - minQuality),
173
+ weight: tq.weight
174
+ }));
175
+ weighted.sort((a, b) => b.quality - a.quality);
176
+ const splitIndex = Math.max(1, Math.ceil(weighted.length * 0.3));
177
+ const highPriority = weighted.slice(0, splitIndex);
178
+ const lowPriority = weighted.slice(splitIndex);
179
+ const highW = highPriority.reduce((s, t) => s + t.weight, 0);
180
+ const lowW = lowPriority.reduce((s, t) => s + t.weight, 0);
181
+ const highAvg = highPriority.reduce((s, t) => s + t.quality * t.weight, 0) / (highW || 1);
182
+ const lowAvg = lowPriority.reduce((s, t) => s + t.quality * t.weight, 0) / (lowW || 1);
183
+ const weightedQuality = highAvg * 0.7 + lowAvg * 0.3;
184
+ const qualities = weighted.map((t) => t.quality);
185
+ const mean = qualities.reduce((s, q) => s + q, 0) / qualities.length;
186
+ const variance = qualities.reduce((s, q) => s + (q - mean) ** 2, 0) / qualities.length;
187
+ const stdDev = Math.sqrt(variance);
188
+ const varianceBoost = stdDev > 12 ? Math.min(5, stdDev * 0.12) : 0;
189
+ return Math.round(Math.min(maxQuality, Math.max(minQuality, weightedQuality + varianceBoost)));
190
+ }
191
+
192
+ // src/core/formats.ts
193
+ var MAGIC_BYTES = {
194
+ jpeg: new Uint8Array([255, 216, 255]),
195
+ png: new Uint8Array([137, 80, 78, 71]),
196
+ webp: new Uint8Array([82, 73, 70, 70]),
197
+ gif: new Uint8Array([71, 73, 70, 56]),
198
+ bmp: new Uint8Array([66, 77]),
199
+ tiff: new Uint8Array([73, 73, 42, 0]),
200
+ ico: new Uint8Array([0, 0, 1, 0]),
201
+ svg: new Uint8Array([60])
202
+ // '<'
203
+ };
204
+ var EXTENSION_MAP = {
205
+ jpg: "jpeg",
206
+ jpeg: "jpeg",
207
+ png: "png",
208
+ webp: "webp",
209
+ avif: "avif",
210
+ gif: "gif",
211
+ svg: "svg",
212
+ bmp: "bmp",
213
+ tiff: "tiff",
214
+ tif: "tiff",
215
+ ico: "ico"
216
+ };
217
+ function detectFormat(buffer, extension) {
218
+ const extFormat = EXTENSION_MAP[extension.toLowerCase()];
219
+ if (extFormat === "svg") return "svg";
220
+ for (const [format, magic] of Object.entries(MAGIC_BYTES)) {
221
+ if (buffer.length >= magic.length) {
222
+ let match = true;
223
+ for (let i = 0; i < magic.length; i++) {
224
+ if (buffer[i] !== magic[i]) {
225
+ match = false;
226
+ break;
227
+ }
228
+ }
229
+ if (match) {
230
+ if (format === "webp") {
231
+ const riffType = new TextDecoder().decode(buffer.slice(8, 12));
232
+ if (riffType === "WEBP") return "webp";
233
+ continue;
234
+ }
235
+ return format;
236
+ }
237
+ }
238
+ }
239
+ return extFormat ?? "jpeg";
240
+ }
241
+ function isAnimatedFormat(format) {
242
+ return format === "gif";
243
+ }
244
+
245
+ // src/core/preprocessor.ts
246
+ import sharp2 from "sharp";
247
+ var OVERLAP_PX = 8;
248
+ async function preprocessImage(imagePath, analysis) {
249
+ const { width, height, tileSize, tiles } = analysis;
250
+ const sorted = [...tiles].sort((a, b) => b.importance - a.importance);
251
+ const topCount = Math.max(1, Math.ceil(sorted.length * 0.2));
252
+ const importantTiles = sorted.slice(0, topCount);
253
+ const composites = [];
254
+ for (const tile of importantTiles) {
255
+ const tileLeft = tile.x * tileSize;
256
+ const tileTop = tile.y * tileSize;
257
+ const tileW = Math.min(tileSize, width - tileLeft);
258
+ const tileH = Math.min(tileSize, height - tileTop);
259
+ const padL = tileLeft > 0 ? OVERLAP_PX : 0;
260
+ const padT = tileTop > 0 ? OVERLAP_PX : 0;
261
+ const padR = tileLeft + tileW < width ? OVERLAP_PX : 0;
262
+ const padB = tileTop + tileH < height ? OVERLAP_PX : 0;
263
+ const extLeft = tileLeft - padL;
264
+ const extTop = tileTop - padT;
265
+ const extWidth = tileW + padL + padR;
266
+ const extHeight = tileH + padT + padB;
267
+ const sigma = 1 + tile.importance * 1.2;
268
+ const sharpened = await sharp2(imagePath).extract({ left: extLeft, top: extTop, width: extWidth, height: extHeight }).sharpen(sigma).png().toBuffer();
269
+ const trimmed = await sharp2(sharpened).extract({ left: padL, top: padT, width: tileW, height: tileH }).png().toBuffer();
270
+ composites.push({ input: trimmed, top: tileTop, left: tileLeft });
271
+ }
272
+ if (composites.length === 0) {
273
+ return sharp2(imagePath).png().toBuffer();
274
+ }
275
+ return sharp2(imagePath).composite(composites.map((c) => ({ input: c.input, top: c.top, left: c.left }))).png().toBuffer();
276
+ }
277
+
278
+ // src/core/sanitizer.ts
279
+ function sanitizeSvg(input) {
280
+ let sanitized = input;
281
+ sanitized = sanitized.replace(/<script[\s\S]*?<\/script>/gi, "");
282
+ sanitized = sanitized.replace(/<\s*script[^>]*\/?>/gi, "");
283
+ sanitized = sanitized.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
284
+ sanitized = sanitized.replace(/javascript\s*:/gi, "");
285
+ sanitized = sanitized.replace(/data\s*:\s*text\s*\/\s*html/gi, "");
286
+ sanitized = sanitized.replace(/document\./gi, "");
287
+ sanitized = sanitized.replace(/window\./gi, "");
288
+ sanitized = sanitized.replace(/eval\s*\(/gi, "");
289
+ sanitized = sanitized.replace(/new\s+Function\s*\(/gi, "");
290
+ sanitized = sanitized.replace(/setTimeout\s*\(/gi, "");
291
+ sanitized = sanitized.replace(/setInterval\s*\(/gi, "");
292
+ return sanitized;
293
+ }
294
+
295
+ // src/core/tuner.ts
296
+ import sharp3 from "sharp";
297
+
298
+ // src/utils/ssim.ts
299
+ function computeSSIM(original, compressed, width, height) {
300
+ const K1 = 0.01;
301
+ const K2 = 0.03;
302
+ const L = 255;
303
+ const C1 = (K1 * L) ** 2;
304
+ const C2 = (K2 * L) ** 2;
305
+ const windowSize = 8;
306
+ const step = 4;
307
+ let totalSSIM = 0;
308
+ let windows = 0;
309
+ for (let y = 0; y <= height - windowSize; y += step) {
310
+ for (let x = 0; x <= width - windowSize; x += step) {
311
+ let sumX = 0;
312
+ let sumY = 0;
313
+ let sumX2 = 0;
314
+ let sumY2 = 0;
315
+ let sumXY = 0;
316
+ let count = 0;
317
+ for (let wy = 0; wy < windowSize; wy++) {
318
+ for (let wx = 0; wx < windowSize; wx++) {
319
+ const idx = (y + wy) * width + (x + wx);
320
+ const ox = original[idx] ?? 0;
321
+ const cy = compressed[idx] ?? 0;
322
+ sumX += ox;
323
+ sumY += cy;
324
+ sumX2 += ox * ox;
325
+ sumY2 += cy * cy;
326
+ sumXY += ox * cy;
327
+ count++;
328
+ }
329
+ }
330
+ const muX = sumX / count;
331
+ const muY = sumY / count;
332
+ const sigmaX2 = sumX2 / count - muX * muX;
333
+ const sigmaY2 = sumY2 / count - muY * muY;
334
+ const sigmaXY = sumXY / count - muX * muY;
335
+ const numerator = (2 * muX * muY + C1) * (2 * sigmaXY + C2);
336
+ const denominator = (muX * muX + muY * muY + C1) * (sigmaX2 + sigmaY2 + C2);
337
+ totalSSIM += denominator > 0 ? numerator / denominator : 1;
338
+ windows++;
339
+ }
340
+ }
341
+ return windows > 0 ? totalSSIM / windows : 1;
342
+ }
343
+
344
+ // src/core/tuner.ts
345
+ var SSIM_THRESHOLD = 0.97;
346
+ var QUALITY_RANGE = [70, 75, 80, 85, 90, 95];
347
+ async function autoTuneQuality(source, format, options) {
348
+ const threshold = options?.threshold ?? SSIM_THRESHOLD;
349
+ const minQ = options?.minQuality ?? 70;
350
+ const maxQ = options?.maxQuality ?? 95;
351
+ const candidateQualities = QUALITY_RANGE.filter((q) => q >= minQ && q <= maxQ);
352
+ const original = await sharp3(source).resize(256, 256, { fit: "inside" }).grayscale().raw().toBuffer();
353
+ const originalMeta = await sharp3(source).metadata();
354
+ const resizeW = Math.min(256, originalMeta.width ?? 256);
355
+ const resizeH = Math.min(256, originalMeta.height ?? 256);
356
+ let best = { quality: minQ, ssim: 1, size: Number.POSITIVE_INFINITY };
357
+ for (const quality of candidateQualities) {
358
+ const { data, info } = await sharp3(source).resize(resizeW, resizeH, { fit: "inside" })[format]({ quality }).grayscale().raw().toBuffer({ resolveWithObject: true });
359
+ const ssim = computeSSIM(
360
+ new Uint8Array(original),
361
+ new Uint8Array(data),
362
+ info.width,
363
+ info.height
364
+ );
365
+ const size = Buffer.byteLength(data);
366
+ if (ssim >= threshold && size < best.size) {
367
+ best = { quality, ssim, size };
368
+ }
369
+ if (ssim >= threshold && quality === minQ) {
370
+ return best;
371
+ }
372
+ }
373
+ if (best.ssim >= threshold) return best;
374
+ return { quality: maxQ, ssim: best.ssim, size: best.size };
375
+ }
376
+
377
+ // src/core/validator.ts
378
+ var ValidationError = class extends Error {
379
+ constructor(message, code) {
380
+ super(message);
381
+ this.code = code;
382
+ this.name = "ValidationError";
383
+ }
384
+ code;
385
+ };
386
+ var MAX_FILE_SIZE = 50 * 1024 * 1024;
387
+ function validatePath(filePath) {
388
+ if (filePath.includes("..")) {
389
+ throw new ValidationError("Path traversal detected", "PATH_TRAVERSAL");
390
+ }
391
+ if (filePath.includes("\0")) {
392
+ throw new ValidationError("Null byte in path", "NULL_BYTE");
393
+ }
394
+ }
395
+ function validateFileSize(size, maxSize = MAX_FILE_SIZE) {
396
+ if (size > maxSize) {
397
+ throw new ValidationError(`File size ${size} exceeds maximum ${maxSize}`, "FILE_TOO_LARGE");
398
+ }
399
+ }
400
+ function validateImageContent(buffer, extension) {
401
+ const detectedFormat = detectFormat(buffer, extension);
402
+ if (!detectedFormat) {
403
+ throw new ValidationError(`Cannot detect image format for .${extension}`, "INVALID_FORMAT");
404
+ }
405
+ return detectedFormat;
406
+ }
407
+ function validateOutputFormat(format) {
408
+ const allowed = ["avif", "webp", "jpeg", "png"];
409
+ if (!allowed.includes(format)) {
410
+ throw new ValidationError(`Unsupported output format: ${format}`, "UNSUPPORTED_OUTPUT_FORMAT");
411
+ }
412
+ }
413
+
414
+ // src/core/encoder.ts
415
+ function readFileSafe(filePath) {
416
+ validatePath(filePath);
417
+ const buffer = readFileSync(filePath);
418
+ validateFileSize(buffer.length);
419
+ return buffer;
420
+ }
421
+ async function encodeVariant(source, width, format, quality, outDir) {
422
+ try {
423
+ validateOutputFormat(format);
424
+ const img = typeof source === "string" ? sharp4(source) : sharp4(source);
425
+ const metadata = await img.metadata();
426
+ if ((metadata.width ?? 0) <= width) {
427
+ const buffer = await img[format]({ quality }).toBuffer();
428
+ const hash2 = contentHash(buffer);
429
+ const ext2 = format === "jpeg" ? "jpg" : format;
430
+ const filename2 = `${hash2}-${width}.${ext2}`;
431
+ const outPath2 = `${outDir}/${filename2}`;
432
+ await sharp4(buffer).toFile(outPath2);
433
+ return {
434
+ src: filename2,
435
+ width,
436
+ format,
437
+ size: buffer.length,
438
+ integrity: sriHash(buffer)
439
+ };
440
+ }
441
+ const resized = typeof source === "string" ? await sharp4(source).resize(width).toBuffer() : await sharp4(source).resize(width).toBuffer();
442
+ const encoded = await sharp4(resized)[format]({ quality }).toBuffer();
443
+ const hash = contentHash(encoded);
444
+ const ext = format === "jpeg" ? "jpg" : format;
445
+ const filename = `${hash}-${width}.${ext}`;
446
+ const outPath = `${outDir}/${filename}`;
447
+ await sharp4(encoded).toFile(outPath);
448
+ return {
449
+ src: filename,
450
+ width,
451
+ format,
452
+ size: encoded.length,
453
+ integrity: sriHash(encoded)
454
+ };
455
+ } catch {
456
+ return null;
457
+ }
458
+ }
459
+ async function encodeImage(imagePath, options) {
460
+ const buffer = readFileSafe(imagePath);
461
+ const ext = imagePath.split(".").pop() ?? "jpg";
462
+ const format = validateImageContent(buffer, ext);
463
+ if (format === "svg") {
464
+ const content = buffer.toString("utf-8");
465
+ const sanitized = sanitizeSvg(content);
466
+ const hash = contentHash(Buffer.from(sanitized));
467
+ const filename = `${hash}.svg`;
468
+ const svgBuffer = Buffer.from(sanitized);
469
+ return {
470
+ src: filename,
471
+ width: 0,
472
+ height: 0,
473
+ format: "svg",
474
+ placeholder: "",
475
+ tiers: {},
476
+ variants: [
477
+ {
478
+ src: filename,
479
+ width: 0,
480
+ format: "webp",
481
+ size: svgBuffer.length,
482
+ integrity: sriHash(svgBuffer)
483
+ }
484
+ ]
485
+ };
486
+ }
487
+ if (isAnimatedFormat(format)) {
488
+ const img = sharp4(buffer, { animated: true });
489
+ const metadata2 = await img.metadata();
490
+ const width = metadata2.width ?? 0;
491
+ const height = metadata2.height ?? 0;
492
+ const webpBuffer = await img.webp({ quality: 75 }).toBuffer();
493
+ const hash = contentHash(webpBuffer);
494
+ const filename = `${hash}.webp`;
495
+ return {
496
+ src: filename,
497
+ width,
498
+ height,
499
+ format: "gif",
500
+ placeholder: "",
501
+ tiers: {},
502
+ variants: [
503
+ {
504
+ src: filename,
505
+ width,
506
+ format: "webp",
507
+ size: webpBuffer.length,
508
+ integrity: sriHash(webpBuffer)
509
+ }
510
+ ]
511
+ };
512
+ }
513
+ const metadata = await sharp4(buffer).metadata();
514
+ const originalWidth = metadata.width ?? 0;
515
+ const originalHeight = metadata.height ?? 0;
516
+ let placeholder = "";
517
+ if (originalWidth > 0 && originalHeight > 0) {
518
+ const placeholderBuffer = await sharp4(buffer).resize(32, 32, { fit: "inside" }).webp({ quality: 20 }).toBuffer();
519
+ placeholder = `data:image/webp;base64,${placeholderBuffer.toString("base64")}`;
520
+ }
521
+ const tiers = {};
522
+ const variants = [];
523
+ let analysis = null;
524
+ let preprocessedSource = null;
525
+ if (options.adaptive || options.preprocess) {
526
+ try {
527
+ analysis = await analyzeImage(imagePath, 64, options.faceDetection);
528
+ } catch {
529
+ }
530
+ }
531
+ if (options.preprocess && analysis) {
532
+ try {
533
+ preprocessedSource = await preprocessImage(imagePath, analysis);
534
+ } catch {
535
+ }
536
+ }
537
+ const source = preprocessedSource ?? imagePath;
538
+ for (const [tierKey, tierConfig] of Object.entries(options.tiers)) {
539
+ const tier = tierKey;
540
+ let quality = tierConfig.quality;
541
+ if (options.adaptive && analysis) {
542
+ try {
543
+ quality = computeTargetQuality(analysis, quality, {
544
+ minQuality: Math.max(20, quality - 30),
545
+ maxQuality: Math.min(95, quality + 10)
546
+ });
547
+ } catch {
548
+ }
549
+ }
550
+ if (options.autoTune) {
551
+ try {
552
+ const tuned = await autoTuneQuality(source, "webp", {
553
+ threshold: 0.97,
554
+ minQuality: Math.max(40, quality - 10),
555
+ maxQuality: Math.min(95, quality + 5)
556
+ });
557
+ quality = tuned.quality;
558
+ } catch {
559
+ }
560
+ }
561
+ const widths = tierConfig.widths.filter((w) => w <= originalWidth);
562
+ if (widths.length === 0) {
563
+ widths.push(originalWidth);
564
+ }
565
+ for (const w of widths) {
566
+ for (const fmt of options.formats) {
567
+ const variant = await encodeVariant(source, w, fmt, quality, options.outDir);
568
+ if (variant) {
569
+ variants.push(variant);
570
+ }
571
+ }
572
+ }
573
+ const bestVariant = variants.find((v) => v.format === "webp");
574
+ if (bestVariant) {
575
+ tiers[tier] = bestVariant.src;
576
+ }
577
+ }
578
+ return {
579
+ src: imagePath.split("/").pop() ?? "image",
580
+ width: originalWidth,
581
+ height: originalHeight,
582
+ format,
583
+ placeholder,
584
+ tiers,
585
+ variants
586
+ };
587
+ }
588
+
589
+ // src/core/manifest.ts
590
+ import { existsSync, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
591
+ import { dirname } from "path";
592
+ var MANIFEST_VERSION = "1.0.0";
593
+ function createManifest() {
594
+ return {
595
+ version: MANIFEST_VERSION,
596
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
597
+ entries: {}
598
+ };
599
+ }
600
+ function addToManifest(manifest, key, entry) {
601
+ manifest.entries[key] = entry;
602
+ }
603
+ function writeManifest(manifest, outPath) {
604
+ const dir = dirname(outPath);
605
+ if (!existsSync(dir)) {
606
+ mkdirSync(dir, { recursive: true });
607
+ }
608
+ writeFileSync(outPath, JSON.stringify(manifest, null, 2));
609
+ }
610
+
611
+ // src/vite-plugin.ts
612
+ var IMAGE_EXTENSIONS = /\.(jpe?g|png|webp|avif|gif|svg|bmp|tiff?|ico)$/i;
613
+ var DEFAULT_TIERS = {
614
+ ultra: { quality: 90, widths: [480, 768, 1024, 1920] },
615
+ high: { quality: 80, widths: [480, 768, 1024] },
616
+ medium: { quality: 60, widths: [480, 768] },
617
+ low: { quality: 40, widths: [480] }
618
+ };
619
+ function gotodevImageOptimizer(userOptions = {}) {
620
+ let config;
621
+ let manifest = createManifest();
622
+ const options = {
623
+ tiers: { ...DEFAULT_TIERS, ...userOptions.tiers },
624
+ adaptive: userOptions.adaptive ?? true,
625
+ autoTune: userOptions.autoTune ?? true,
626
+ preprocess: userOptions.preprocess ?? true,
627
+ faceDetection: userOptions.faceDetection ?? true,
628
+ formats: userOptions.formats ?? ["avif", "webp", "jpeg"],
629
+ maxFileSize: userOptions.maxFileSize ?? 50 * 1024 * 1024,
630
+ verbose: userOptions.verbose ?? false
631
+ };
632
+ return {
633
+ name: "gotodev-image-optimizer",
634
+ enforce: "post",
635
+ configResolved(resolved) {
636
+ config = resolved;
637
+ },
638
+ async buildStart() {
639
+ manifest = createManifest();
640
+ },
641
+ async transform(_code, id) {
642
+ if (!IMAGE_EXTENSIONS.test(id)) return;
643
+ if (id.includes("node_modules")) return;
644
+ const outDir = resolve(config.root, config.build.outDir ?? "dist", "assets");
645
+ if (!existsSync2(outDir)) {
646
+ mkdirSync2(outDir, { recursive: true });
647
+ }
648
+ const tiers = options.tiers;
649
+ const formats = options.formats ?? ["avif", "webp", "jpeg"];
650
+ try {
651
+ const entry = await encodeImage(id, {
652
+ widths: tiers.high.widths,
653
+ formats: [...formats],
654
+ tiers,
655
+ autoTune: options.autoTune ?? true,
656
+ adaptive: options.adaptive ?? true,
657
+ preprocess: options.preprocess ?? true,
658
+ faceDetection: options.faceDetection ?? true,
659
+ outDir
660
+ });
661
+ const key = basename(id);
662
+ addToManifest(manifest, key, entry);
663
+ if (options.verbose) {
664
+ console.log(`[gotodev-image-optimizer] Optimized: ${key}`);
665
+ }
666
+ const manifestData = JSON.stringify(entry);
667
+ const manifestPath = resolve(outDir, "gimage-manifest.json");
668
+ writeManifest(manifest, manifestPath);
669
+ return {
670
+ code: `export default ${manifestData};`,
671
+ map: null
672
+ };
673
+ } catch (error) {
674
+ if (options.verbose) {
675
+ console.error(`[gotodev-image-optimizer] Failed to optimize ${id}:`, error);
676
+ }
677
+ return {
678
+ code: `export default ${JSON.stringify({
679
+ src: basename(id),
680
+ width: 0,
681
+ height: 0,
682
+ format: extname(id).slice(1),
683
+ placeholder: "",
684
+ variants: [],
685
+ tiers: {}
686
+ })};`,
687
+ map: null
688
+ };
689
+ }
690
+ },
691
+ closeBundle() {
692
+ const outDir = resolve(config.root, config.build.outDir ?? "dist", "assets");
693
+ if (!existsSync2(outDir)) {
694
+ mkdirSync2(outDir, { recursive: true });
695
+ }
696
+ const manifestPath = resolve(outDir, "gimage-manifest.json");
697
+ writeManifest(manifest, manifestPath);
698
+ }
699
+ };
700
+ }
701
+
702
+ export {
703
+ isAnimatedFormat,
704
+ gotodevImageOptimizer
705
+ };