img-subir 0.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.
@@ -0,0 +1,1004 @@
1
+ // src/errors.ts
2
+ var UsageError = class extends Error {
3
+ name = "UsageError";
4
+ };
5
+
6
+ // src/presets.ts
7
+ var stdFloor = { ui: 0.985, photo: 0.96, graphic: 0.985 };
8
+ var stdQuality = {
9
+ ui: { webp: 82, avif: 60, jpeg: 85, png: 95 },
10
+ photo: { webp: 80, avif: 55, jpeg: 78, png: 90 },
11
+ graphic: { webp: 90, avif: 65, jpeg: 88, png: 100 }
12
+ };
13
+ var base = {
14
+ floor: stdFloor,
15
+ quality: stdQuality,
16
+ avifMinGain: 0.15,
17
+ avifMaxPixels: 4e6,
18
+ losslessWebp: false,
19
+ lossless: false,
20
+ minGainPercent: 5
21
+ };
22
+ var presets = {
23
+ web: {
24
+ ...base,
25
+ name: "web",
26
+ maxWidth: 1920,
27
+ candidates: ["webp", "avif", "png", "jpeg"],
28
+ note: "Website assets, Subir pages, docs. AVIF kept only when \u226515% smaller than WebP."
29
+ },
30
+ github: {
31
+ ...base,
32
+ name: "github",
33
+ maxWidth: 1600,
34
+ candidates: ["webp", "png", "jpeg"],
35
+ note: "README / PR screenshots. Never emits AVIF."
36
+ },
37
+ screenshot: {
38
+ ...base,
39
+ name: "screenshot",
40
+ maxWidth: 1600,
41
+ candidates: ["webp", "png"],
42
+ floor: { ui: 0.99, photo: 0.99, graphic: 0.99 },
43
+ losslessWebp: true,
44
+ forceKind: "ui",
45
+ note: "Crisp UI captures. Forces kind=ui and also tries lossless WebP."
46
+ },
47
+ og: {
48
+ ...base,
49
+ name: "og",
50
+ maxWidth: 1200,
51
+ maxHeight: 630,
52
+ candidates: ["jpeg", "png"],
53
+ floor: { ui: 0.97, photo: 0.95, graphic: 0.97 },
54
+ note: "Open Graph / social cards. JPEG/PNG only; many crawlers dislike WebP."
55
+ },
56
+ thumb: {
57
+ ...base,
58
+ name: "thumb",
59
+ maxWidth: 480,
60
+ candidates: ["webp", "avif"],
61
+ floor: { ui: 0.95, photo: 0.93, graphic: 0.95 },
62
+ quality: {
63
+ ui: { webp: 75, avif: 50, jpeg: 75, png: 85 },
64
+ photo: { webp: 70, avif: 45, jpeg: 70, png: 80 },
65
+ graphic: { webp: 80, avif: 55, jpeg: 80, png: 90 }
66
+ },
67
+ note: "Thumbnails, avatars, cards. Aggressive; pair with --target-kb."
68
+ },
69
+ lossless: {
70
+ ...base,
71
+ name: "lossless",
72
+ candidates: ["png", "webp"],
73
+ floor: { ui: 1, photo: 1, graphic: 1 },
74
+ losslessWebp: true,
75
+ lossless: true,
76
+ minGainPercent: 1,
77
+ note: "Pixel-exact. No resize, no lossy encoders."
78
+ }
79
+ };
80
+ var presetNames = Object.keys(presets);
81
+ function getPreset(name) {
82
+ const n = name ?? "web";
83
+ const p = presets[n];
84
+ if (!p) {
85
+ throw new UsageError(`Unknown preset "${name}". Valid presets: ${presetNames.join(", ")}`);
86
+ }
87
+ return p;
88
+ }
89
+
90
+ // src/core/classify.ts
91
+ import path from "path";
92
+ import sharp from "sharp";
93
+ var UI_NAME_HINTS = /screen ?shot|cleanshot|@[23]x|capture|screencap|frame|mockup|ui-|-ui\b/i;
94
+ var PHOTO_NAME_HINTS = /\b(img|dsc|pxl|imag|photo|pic)[_-]?\d{3,}/i;
95
+ var DEVICE_SIZES = /* @__PURE__ */ new Set([
96
+ "2880x1800",
97
+ "3024x1964",
98
+ "3456x2234",
99
+ "2560x1600",
100
+ "2560x1664",
101
+ "3072x1920",
102
+ "5120x2880",
103
+ "3840x2160",
104
+ "1170x2532",
105
+ "1179x2556",
106
+ "1290x2796",
107
+ "1284x2778",
108
+ "1125x2436",
109
+ "1080x2340",
110
+ "1440x3120",
111
+ "2048x2732"
112
+ ]);
113
+ async function classify(info, input = info.path) {
114
+ const { data, info: raw } = await sharp(input, { pages: 1 }).rotate().resize({ width: 256, height: 256, fit: "inside", withoutEnlargement: true, kernel: "nearest" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
115
+ const { width, height, channels } = raw;
116
+ const colors = /* @__PURE__ */ new Set();
117
+ let flat = 0;
118
+ let total = 0;
119
+ let transparent = 0;
120
+ for (let y = 0; y < height; y++) {
121
+ for (let x = 0; x < width; x++) {
122
+ const i = (y * width + x) * channels;
123
+ const c = data[i] << 16 | data[i + 1] << 8 | data[i + 2];
124
+ if (colors.size < 2e5) colors.add(c);
125
+ if (data[i + 3] < 255) transparent++;
126
+ if (x > 0) {
127
+ const j = i - channels;
128
+ total++;
129
+ if (data[j] === data[i] && data[j + 1] === data[i + 1] && data[j + 2] === data[i + 2]) flat++;
130
+ }
131
+ }
132
+ }
133
+ const flatShare = total ? flat / total : 0;
134
+ const distinctColors = colors.size;
135
+ const transparentShare = width * height ? transparent / (width * height) : 0;
136
+ const hasTransparency = transparentShare > 1e-3;
137
+ let score = 0;
138
+ if (flatShare > 0.45) score += 2;
139
+ else if (flatShare > 0.25) score += 1;
140
+ else if (flatShare < 0.08) score -= 2;
141
+ else if (flatShare < 0.15) score -= 1;
142
+ if (distinctColors < 2e3) score += 1;
143
+ else if (distinctColors > 2e4) score -= 1;
144
+ const name = path.basename(info.path);
145
+ if (UI_NAME_HINTS.test(name)) score += 1;
146
+ if (PHOTO_NAME_HINTS.test(name)) score -= 1;
147
+ if (DEVICE_SIZES.has(`${info.width}x${info.height}`)) score += 1;
148
+ let kind;
149
+ if (hasTransparency && distinctColors < 4e3 && flatShare > 0.3) kind = "graphic";
150
+ else if (score >= 1) kind = "ui";
151
+ else if (score <= -1) kind = "photo";
152
+ else kind = flatShare > 0.2 ? "ui" : "photo";
153
+ return { kind, stats: { distinctColors, flatShare: Number(flatShare.toFixed(3)), transparentShare: Number(transparentShare.toFixed(4)), score } };
154
+ }
155
+
156
+ // src/core/discover.ts
157
+ import fg from "fast-glob";
158
+ import { stat } from "fs/promises";
159
+ import path2 from "path";
160
+ var BACKUP_DIR = ".img-subir-originals";
161
+ var IMAGE_EXTS = ["png", "jpg", "jpeg", "webp", "avif", "gif", "svg"];
162
+ var IGNORE = ["**/node_modules/**", "**/.git/**", `**/${BACKUP_DIR}/**`];
163
+ function extPattern() {
164
+ const all = IMAGE_EXTS.flatMap((e) => [e, e.toUpperCase()]);
165
+ return `**/*.{${all.join(",")}}`;
166
+ }
167
+ async function discover(inputs, cwd, excludeDirs = []) {
168
+ const files = /* @__PURE__ */ new Set();
169
+ const roots = /* @__PURE__ */ new Set();
170
+ const ignore = IGNORE;
171
+ const excluded = (f) => excludeDirs.some((d) => f === d || f.startsWith(d + path2.sep));
172
+ for (const input of inputs) {
173
+ const abs = path2.resolve(cwd, input);
174
+ let s = null;
175
+ try {
176
+ s = await stat(abs);
177
+ } catch {
178
+ s = null;
179
+ }
180
+ if (s?.isDirectory()) {
181
+ roots.add(abs);
182
+ const found = await fg(extPattern(), { cwd: abs, absolute: true, ignore, onlyFiles: true, dot: false });
183
+ for (const f of found) files.add(path2.normalize(f));
184
+ } else if (s?.isFile()) {
185
+ files.add(abs);
186
+ roots.add(path2.dirname(abs));
187
+ } else {
188
+ const found = await fg(input, { cwd, absolute: true, ignore, onlyFiles: true, dot: false });
189
+ if (found.length === 0) {
190
+ throw new UsageError(`No such file, directory or glob match: ${input}`);
191
+ }
192
+ for (const f of found) files.add(path2.normalize(f));
193
+ roots.add(cwd);
194
+ }
195
+ }
196
+ return { files: [...files].filter((f) => !excluded(f)).sort(), roots: [...roots] };
197
+ }
198
+
199
+ // src/core/probe.ts
200
+ import { stat as stat2 } from "fs/promises";
201
+ import path3 from "path";
202
+ import sharp2 from "sharp";
203
+ function normalizeFormat(f, filePath) {
204
+ switch (f) {
205
+ case "jpeg":
206
+ case "jpg":
207
+ return "jpeg";
208
+ case "png":
209
+ case "webp":
210
+ case "gif":
211
+ case "svg":
212
+ return f;
213
+ case "heif":
214
+ return "avif";
215
+ default: {
216
+ const ext = path3.extname(filePath).slice(1).toLowerCase();
217
+ if (ext === "jpg") return "jpeg";
218
+ if (ext === "avif") return "avif";
219
+ return ext || "png";
220
+ }
221
+ }
222
+ }
223
+ async function probe(filePath) {
224
+ const s = await stat2(filePath);
225
+ const ext = path3.extname(filePath).toLowerCase();
226
+ if (ext === ".svg") {
227
+ let width2 = 0;
228
+ let height2 = 0;
229
+ try {
230
+ const m2 = await sharp2(filePath).metadata();
231
+ width2 = m2.width ?? 0;
232
+ height2 = m2.height ?? 0;
233
+ } catch {
234
+ }
235
+ return { path: filePath, format: "svg", width: width2, height: height2, bytes: s.size, hasAlpha: true, animated: false };
236
+ }
237
+ const m = await sharp2(filePath, { animated: true }).metadata();
238
+ const pages = m.pages ?? 1;
239
+ const width = m.width ?? 0;
240
+ const height = pages > 1 ? m.pageHeight ?? Math.round((m.height ?? 0) / pages) : m.height ?? 0;
241
+ const swap = m.orientation !== void 0 && m.orientation >= 5;
242
+ return {
243
+ path: filePath,
244
+ format: normalizeFormat(m.format, filePath),
245
+ width: swap ? height : width,
246
+ height: swap ? width : height,
247
+ bytes: s.size,
248
+ hasAlpha: Boolean(m.hasAlpha),
249
+ animated: pages > 1
250
+ };
251
+ }
252
+
253
+ // src/core/rewrite-refs.ts
254
+ import fg2 from "fast-glob";
255
+ import { readFile, writeFile } from "fs/promises";
256
+ import path4 from "path";
257
+ var DEFAULT_REF_GLOBS = ["**/*.{html,htm,md,mdx,tsx,jsx,ts,js,mjs,css,scss,vue,svelte,astro,json,yml,yaml}"];
258
+ function escapeRe(s) {
259
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
260
+ }
261
+ function toPosix(p) {
262
+ return p.split(path4.sep).join("/");
263
+ }
264
+ function spellings(textFile, target, roots) {
265
+ const out = /* @__PURE__ */ new Set();
266
+ const rel = toPosix(path4.relative(path4.dirname(textFile), target));
267
+ if (rel && !rel.startsWith("/")) {
268
+ out.add(rel);
269
+ if (!rel.startsWith(".")) out.add(`./${rel}`);
270
+ }
271
+ for (const root of roots) {
272
+ const fromRoot = toPosix(path4.relative(root, target));
273
+ if (fromRoot && !fromRoot.startsWith("..") && !path4.isAbsolute(fromRoot)) out.add(`/${fromRoot}`);
274
+ }
275
+ for (const s of [...out]) {
276
+ const enc = encodeURI(s);
277
+ if (enc !== s) out.add(enc);
278
+ }
279
+ return [...out];
280
+ }
281
+ var LEAD = String.raw`(^|[\s"'\`(<=,])`;
282
+ var TRAIL = String.raw`(?=$|[\s"'\`)>?#,;])`;
283
+ async function rewriteRefs(renamed, roots, globs = DEFAULT_REF_GLOBS, dryRun = false) {
284
+ if (renamed.length === 0) return [];
285
+ const results = [];
286
+ const renamedSet = new Set(renamed.map((r) => path4.normalize(r.to)));
287
+ const seen = /* @__PURE__ */ new Set();
288
+ for (const root of roots) {
289
+ const files = (await fg2(globs, {
290
+ cwd: root,
291
+ absolute: true,
292
+ onlyFiles: true,
293
+ ignore: ["**/node_modules/**", "**/.git/**", `**/${BACKUP_DIR}/**`, "**/dist/**", "**/build/**"]
294
+ })).map((f) => path4.normalize(f));
295
+ for (const file of files) {
296
+ if (seen.has(file) || renamedSet.has(file)) continue;
297
+ seen.add(file);
298
+ let text;
299
+ try {
300
+ text = await readFile(file, "utf8");
301
+ } catch {
302
+ continue;
303
+ }
304
+ if (text.includes("\0")) continue;
305
+ let changed = false;
306
+ for (const r of renamed) {
307
+ const fromSpellings = spellings(file, r.from, roots);
308
+ const toSpellings = spellings(file, r.to, roots);
309
+ let count = 0;
310
+ for (let i = 0; i < fromSpellings.length; i++) {
311
+ const from = fromSpellings[i];
312
+ const to = toSpellings[i] ?? toSpellings[0];
313
+ const re = new RegExp(`${LEAD}${escapeRe(from)}${TRAIL}`, "g");
314
+ text = text.replace(re, (_m, pre) => {
315
+ count++;
316
+ return `${pre}${to}`;
317
+ });
318
+ }
319
+ if (count > 0) {
320
+ changed = true;
321
+ results.push({ file, from: r.from, to: r.to, count });
322
+ }
323
+ }
324
+ if (changed && !dryRun) await writeFile(file, text);
325
+ }
326
+ }
327
+ return results;
328
+ }
329
+
330
+ // src/version.ts
331
+ import { createRequire } from "module";
332
+ var require2 = createRequire(import.meta.url);
333
+ var VERSION = require2("../package.json").version;
334
+
335
+ // src/core/optimize.ts
336
+ import { access as access2, copyFile, mkdir as mkdir2, readFile as readFile2 } from "fs/promises";
337
+ import os from "os";
338
+ import path6 from "path";
339
+ import sharp4 from "sharp";
340
+
341
+ // src/core/quality.ts
342
+ import sharp3 from "sharp";
343
+ import { createRequire as createRequire2 } from "module";
344
+ var require3 = createRequire2(import.meta.url);
345
+ var ssimLib = require3("ssim.js");
346
+ var ssim = ssimLib.default ?? ssimLib;
347
+ var SSIM_MAX_WIDTH = 512;
348
+ var FLATTEN_BG = { r: 128, g: 128, b: 128 };
349
+ function asImage(data, width, height) {
350
+ return { data: new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength), width, height };
351
+ }
352
+ async function toComparable(input, width, hasAlpha, height) {
353
+ const target = Math.min(SSIM_MAX_WIDTH, width);
354
+ const resize = height ? { width: target, height, fit: "fill" } : { width: target, withoutEnlargement: true, kernel: "lanczos3" };
355
+ const base2 = input.clone().resize(resize).ensureAlpha();
356
+ const { data: rgba, info } = await base2.clone().raw().toBuffer({ resolveWithObject: true });
357
+ const flat = await sharp3(rgba, { raw: { width: info.width, height: info.height, channels: 4 } }).flatten({ background: FLATTEN_BG }).ensureAlpha().raw().toBuffer();
358
+ const out = { color: asImage(flat, info.width, info.height), width: info.width, height: info.height };
359
+ if (hasAlpha) {
360
+ const a = Buffer.alloc(info.width * info.height * 4);
361
+ for (let i = 0, j = 0; i < rgba.length; i += 4, j += 4) {
362
+ const v = rgba[i + 3];
363
+ a[j] = v;
364
+ a[j + 1] = v;
365
+ a[j + 2] = v;
366
+ a[j + 3] = 255;
367
+ }
368
+ out.alpha = asImage(a, info.width, info.height);
369
+ }
370
+ return out;
371
+ }
372
+ function fromRaw(raw) {
373
+ return sharp3(raw.data, { raw: { width: raw.width, height: raw.height, channels: raw.channels } });
374
+ }
375
+ async function compareSsim(reference, encoded) {
376
+ const hasAlpha = Boolean(reference.alpha);
377
+ let cand = await toComparable(sharp3(encoded, { pages: 1 }), reference.width, hasAlpha);
378
+ if (cand.width !== reference.width || cand.height !== reference.height) {
379
+ cand = await toComparable(sharp3(encoded, { pages: 1 }), reference.width, hasAlpha, reference.height);
380
+ }
381
+ let score = ssim(reference.color, cand.color, { ssim: "fast" }).mssim;
382
+ if (reference.alpha && cand.alpha) score = Math.min(score, ssim(reference.alpha, cand.alpha, { ssim: "fast" }).mssim);
383
+ return score;
384
+ }
385
+ function alphaIsOpaque(raw) {
386
+ if (raw.channels !== 4) return true;
387
+ const d = raw.data;
388
+ for (let i = 3; i < d.length; i += 4) if (d[i] !== 255) return false;
389
+ return true;
390
+ }
391
+ function stripAlpha(raw) {
392
+ if (raw.channels !== 4) return raw;
393
+ const px = raw.width * raw.height;
394
+ const out = Buffer.alloc(px * 3);
395
+ const d = raw.data;
396
+ for (let i = 0, j = 0; i < px; i++, j += 3) {
397
+ const k = i * 4;
398
+ out[j] = d[k];
399
+ out[j + 1] = d[k + 1];
400
+ out[j + 2] = d[k + 2];
401
+ }
402
+ return { data: out, width: raw.width, height: raw.height, channels: 3 };
403
+ }
404
+
405
+ // src/core/encode.ts
406
+ function encode(raw, spec) {
407
+ const img = fromRaw(raw);
408
+ return applyEncoder(img, spec).toBuffer();
409
+ }
410
+ function applyEncoder(img, spec) {
411
+ const q = spec.quality ?? 80;
412
+ switch (spec.format) {
413
+ case "webp":
414
+ return spec.lossless ? img.webp({ lossless: true, effort: 6 }) : img.webp({ quality: q, effort: 4, alphaQuality: 90, smartSubsample: true });
415
+ case "avif":
416
+ return img.avif({ quality: q, effort: 4, chromaSubsampling: "4:2:0" });
417
+ case "jpeg":
418
+ return img.jpeg({ quality: q, mozjpeg: true, progressive: true });
419
+ case "png":
420
+ return spec.lossless ? img.png({ compressionLevel: 9, adaptiveFiltering: true, palette: false, effort: 10 }) : img.png({ compressionLevel: 9, palette: true, quality: q, effort: 8, dither: 0.6 });
421
+ case "gif":
422
+ return img.gif({ effort: 8 });
423
+ }
424
+ }
425
+ function extFor(format) {
426
+ return format === "jpeg" ? ".jpg" : `.${format}`;
427
+ }
428
+
429
+ // src/core/svg.ts
430
+ import { optimize as svgoOptimize } from "svgo";
431
+ function optimizeSvg(source) {
432
+ const r = svgoOptimize(source, {
433
+ multipass: true,
434
+ plugins: [
435
+ {
436
+ name: "preset-default",
437
+ params: {
438
+ overrides: {
439
+ cleanupIds: false
440
+ }
441
+ }
442
+ }
443
+ ]
444
+ });
445
+ return r.data;
446
+ }
447
+
448
+ // src/core/write.ts
449
+ import { access, mkdir, rename, rm, writeFile as writeFile2 } from "fs/promises";
450
+ import path5 from "path";
451
+ import { randomBytes } from "crypto";
452
+ async function writeOutput(data, plan) {
453
+ const outDir = path5.dirname(plan.outputPath);
454
+ await mkdir(outDir, { recursive: true });
455
+ const tmp = path5.join(outDir, `.${path5.basename(plan.outputPath)}.${randomBytes(6).toString("hex")}.tmp`);
456
+ await writeFile2(tmp, data, { flag: "wx" });
457
+ const res = {};
458
+ const samePath = path5.resolve(plan.inputPath) === path5.resolve(plan.outputPath);
459
+ try {
460
+ if (plan.separateOut && !samePath) {
461
+ await rename(tmp, plan.outputPath);
462
+ return res;
463
+ }
464
+ if (samePath) {
465
+ let backupPath;
466
+ if (plan.backup) {
467
+ backupPath = await moveToBackup(plan.inputPath);
468
+ res.backupPath = backupPath;
469
+ }
470
+ try {
471
+ await rename(tmp, plan.outputPath);
472
+ } catch (e) {
473
+ if (backupPath) await rename(backupPath, plan.inputPath).catch(() => void 0);
474
+ throw e;
475
+ }
476
+ return res;
477
+ }
478
+ await rename(tmp, plan.outputPath);
479
+ if (plan.backup) res.backupPath = await moveToBackup(plan.inputPath);
480
+ else await rm(plan.inputPath, { force: true });
481
+ return res;
482
+ } finally {
483
+ await rm(tmp, { force: true }).catch(() => void 0);
484
+ }
485
+ }
486
+ async function moveToBackup(inputPath) {
487
+ const backupDir = path5.join(path5.dirname(inputPath), BACKUP_DIR);
488
+ await mkdir(backupDir, { recursive: true });
489
+ const backupPath = await uniquePath(path5.join(backupDir, path5.basename(inputPath)));
490
+ await rename(inputPath, backupPath);
491
+ return backupPath;
492
+ }
493
+ async function uniquePath(p) {
494
+ let candidate = p;
495
+ let i = 1;
496
+ for (; ; ) {
497
+ try {
498
+ await access(candidate);
499
+ } catch {
500
+ return candidate;
501
+ }
502
+ const ext = path5.extname(p);
503
+ candidate = path5.join(path5.dirname(p), `${path5.basename(p, ext)}.${i++}${ext}`);
504
+ }
505
+ }
506
+
507
+ // src/core/optimize.ts
508
+ var LOSSY_INPUTS = /* @__PURE__ */ new Set(["webp", "jpeg", "avif"]);
509
+ function resolveOptions(opts) {
510
+ return {
511
+ ...opts,
512
+ cwd: opts.cwd ?? process.cwd(),
513
+ backup: opts.backup ?? true,
514
+ rename: opts.rename ?? true,
515
+ dryRun: opts.dryRun ?? false,
516
+ fast: opts.fast ?? false,
517
+ format: opts.format ?? "auto",
518
+ kind: opts.kind ?? "auto"
519
+ };
520
+ }
521
+ async function optimize(options) {
522
+ const started = Date.now();
523
+ const opts = resolveOptions(options);
524
+ const preset = getPreset(opts.preset);
525
+ const outAbs = opts.out ? path6.resolve(opts.cwd, opts.out) : void 0;
526
+ const { files, roots } = await discover(opts.paths, opts.cwd, outAbs ? [outAbs] : []);
527
+ if (outAbs && roots.some((r) => path6.resolve(r) === outAbs)) {
528
+ throw new UsageError(`--out must differ from the input directory (${path6.relative(opts.cwd, outAbs) || "."}); drop --out to optimize in place`);
529
+ }
530
+ const ctx = { opts, preset, roots };
531
+ const concurrency = Math.max(1, opts.concurrency ?? Math.max(1, os.cpus().length - 1));
532
+ const results = new Array(files.length);
533
+ let next = 0;
534
+ await Promise.all(
535
+ Array.from({ length: Math.min(concurrency, files.length) }, async () => {
536
+ while (next < files.length) {
537
+ const i = next++;
538
+ const r = await optimizeFile(files[i], ctx);
539
+ results[i] = r;
540
+ opts.onFile?.(r);
541
+ }
542
+ })
543
+ );
544
+ const renamed = results.filter((r) => r.output && r.status !== "failed" && r.status !== "kept" && path6.basename(r.output.path) !== path6.basename(r.input.path)).map((r) => ({ from: r.input.path, to: r.output.path }));
545
+ let rewrittenRefs = [];
546
+ if (opts.rewriteRefs && !outAbs) {
547
+ const globs = Array.isArray(opts.rewriteRefs) ? opts.rewriteRefs : DEFAULT_REF_GLOBS;
548
+ rewrittenRefs = await rewriteRefs(renamed, roots, globs, opts.dryRun);
549
+ }
550
+ const backupDirs = [...new Set(results.filter((r) => r.backupPath).map((r) => path6.dirname(r.backupPath)))];
551
+ return {
552
+ schemaVersion: 1,
553
+ version: VERSION,
554
+ preset: preset.name,
555
+ dryRun: opts.dryRun,
556
+ files: results,
557
+ totals: totals(results, Date.now() - started),
558
+ renamed,
559
+ rewrittenRefs,
560
+ backupDirs
561
+ };
562
+ }
563
+ function totals(results, durationMs) {
564
+ const t = {
565
+ files: results.length,
566
+ inputBytes: 0,
567
+ outputBytes: 0,
568
+ savedBytes: 0,
569
+ savedPercent: 0,
570
+ optimized: 0,
571
+ kept: 0,
572
+ failed: 0,
573
+ budgetMissed: 0,
574
+ durationMs
575
+ };
576
+ for (const r of results) {
577
+ t.inputBytes += r.input.bytes;
578
+ t.outputBytes += r.output && r.status !== "kept" && r.status !== "failed" ? r.output.bytes : r.input.bytes;
579
+ if (r.status === "optimized") t.optimized++;
580
+ else if (r.status === "kept") t.kept++;
581
+ else if (r.status === "failed") t.failed++;
582
+ else if (r.status === "budget-missed") t.budgetMissed++;
583
+ }
584
+ t.savedBytes = t.inputBytes - t.outputBytes;
585
+ t.savedPercent = t.inputBytes ? round1(t.savedBytes / t.inputBytes * 100) : 0;
586
+ return t;
587
+ }
588
+ function round1(n) {
589
+ return Math.round(n * 10) / 10;
590
+ }
591
+ function isInside(file, dir) {
592
+ const rel = path6.relative(dir, file);
593
+ return rel !== "" && !rel.startsWith("..") && !path6.isAbsolute(rel);
594
+ }
595
+ function rootFor(file, roots) {
596
+ let best = "";
597
+ for (const r of roots) if (isInside(file, r) && r.length > best.length) best = r;
598
+ return best || path6.dirname(file);
599
+ }
600
+ function mirrorPath(input, ctx) {
601
+ const root = rootFor(input, ctx.roots);
602
+ return path6.join(path6.resolve(ctx.opts.cwd, ctx.opts.out), path6.relative(root, input));
603
+ }
604
+ function outputPathFor(input, format, ctx) {
605
+ const { opts } = ctx;
606
+ const parsed = path6.parse(input);
607
+ const ext = opts.rename ? extFor(format) : parsed.ext;
608
+ const name = `${parsed.name}${opts.suffix ?? ""}${ext}`;
609
+ if (opts.out) {
610
+ const root = rootFor(input, ctx.roots);
611
+ return path6.join(path6.resolve(opts.cwd, opts.out), path6.relative(root, parsed.dir), name);
612
+ }
613
+ return path6.join(parsed.dir, name);
614
+ }
615
+ function targetSize(info, ctx) {
616
+ const maxW = ctx.opts.maxWidth ?? (ctx.preset.lossless ? void 0 : ctx.preset.maxWidth);
617
+ const maxH = ctx.opts.maxHeight ?? (ctx.preset.lossless ? void 0 : ctx.preset.maxHeight);
618
+ let { width, height } = info;
619
+ if (!width || !height) return { width, height, resized: false };
620
+ let scale = 1;
621
+ if (maxW && width > maxW) scale = Math.min(scale, maxW / width);
622
+ if (maxH && height > maxH) scale = Math.min(scale, maxH / height);
623
+ if (scale < 1) {
624
+ width = Math.max(1, Math.round(width * scale));
625
+ height = Math.max(1, Math.round(height * scale));
626
+ return { width, height, resized: true };
627
+ }
628
+ return { width, height, resized: false };
629
+ }
630
+ function allowedFormats(info, ctx) {
631
+ const f = ctx.opts.format ?? "auto";
632
+ if (f === "keep") return info.format === "svg" ? [] : [info.format === "gif" ? "webp" : info.format];
633
+ if (f !== "auto") return [f];
634
+ return ctx.preset.candidates;
635
+ }
636
+ function buildSpecs(info, kind, pixels, ctx) {
637
+ const { preset, opts } = ctx;
638
+ const formats = allowedFormats(info, ctx);
639
+ const explicit = opts.format && opts.format !== "auto" && opts.format !== "keep";
640
+ const q = preset.quality[kind];
641
+ const specs = [];
642
+ for (const f of formats) {
643
+ switch (f) {
644
+ case "webp":
645
+ if (!preset.lossless) specs.push({ format: "webp", quality: opts.quality ?? q.webp });
646
+ if (preset.lossless || preset.losslessWebp && kind !== "photo") specs.push({ format: "webp", lossless: true });
647
+ break;
648
+ case "avif":
649
+ if (explicit || pixels <= preset.avifMaxPixels) specs.push({ format: "avif", quality: opts.quality ?? q.avif });
650
+ break;
651
+ case "jpeg":
652
+ if (!info.hasAlpha && !preset.lossless) specs.push({ format: "jpeg", quality: opts.quality ?? q.jpeg });
653
+ break;
654
+ case "png":
655
+ if (preset.lossless || kind === "graphic") specs.push({ format: "png", lossless: true });
656
+ if (!preset.lossless) specs.push({ format: "png", quality: opts.quality ?? q.png });
657
+ break;
658
+ case "gif":
659
+ break;
660
+ }
661
+ }
662
+ return specs;
663
+ }
664
+ async function optimizeFile(file, ctx) {
665
+ const started = Date.now();
666
+ let info;
667
+ try {
668
+ info = await probe(file);
669
+ } catch (e) {
670
+ return failed({ path: file, format: "png", width: 0, height: 0, bytes: 0, hasAlpha: false, animated: false }, e, started);
671
+ }
672
+ let result;
673
+ try {
674
+ if (info.format === "svg") result = await handleSvg(info, ctx, started);
675
+ else if (info.animated) result = await handleAnimated(info, ctx, started);
676
+ else result = await handleRaster(info, ctx, started);
677
+ } catch (e) {
678
+ return failed(info, e, started);
679
+ }
680
+ if (result.status === "kept" && ctx.opts.out) {
681
+ const dest = mirrorPath(info.path, ctx);
682
+ if (!ctx.opts.dryRun) {
683
+ try {
684
+ await mkdir2(path6.dirname(dest), { recursive: true });
685
+ await copyFile(info.path, dest);
686
+ } catch (e) {
687
+ return failed(info, e, started);
688
+ }
689
+ }
690
+ result.copiedTo = dest;
691
+ }
692
+ if (ctx.opts.targetKb && result.status !== "failed") {
693
+ const finalBytes = result.output && result.status !== "kept" ? result.output.bytes : result.input.bytes;
694
+ if (finalBytes > ctx.opts.targetKb * 1024) result.status = "budget-missed";
695
+ }
696
+ return result;
697
+ }
698
+ function failed(info, e, started) {
699
+ return {
700
+ input: info,
701
+ output: null,
702
+ kind: "photo",
703
+ status: "failed",
704
+ savedBytes: 0,
705
+ savedPercent: 0,
706
+ actions: [],
707
+ candidates: [],
708
+ error: e instanceof Error ? e.message : String(e),
709
+ durationMs: Date.now() - started
710
+ };
711
+ }
712
+ function kept(info, kind, reason, candidates, actions, started) {
713
+ return { input: info, output: null, kind, status: "kept", savedBytes: 0, savedPercent: 0, actions, candidates, reason, durationMs: Date.now() - started };
714
+ }
715
+ async function exists(p) {
716
+ try {
717
+ await access2(p);
718
+ return true;
719
+ } catch {
720
+ return false;
721
+ }
722
+ }
723
+ async function finish(info, kind, data, out, candidates, actions, ctx, started, status = "optimized") {
724
+ const separateOut = Boolean(ctx.opts.out) && path6.resolve(out.path) !== path6.resolve(info.path);
725
+ if (out.path !== info.path && !separateOut && await exists(out.path)) {
726
+ return kept(info, kind, `output path already exists: ${path6.basename(out.path)} (remove it or use --suffix/--out)`, candidates, actions, started);
727
+ }
728
+ let backupPath;
729
+ if (!ctx.opts.dryRun) {
730
+ const w = await writeOutput(data, { inputPath: info.path, outputPath: out.path, backup: ctx.opts.backup, separateOut });
731
+ backupPath = w.backupPath;
732
+ }
733
+ const savedBytes = info.bytes - out.bytes;
734
+ return {
735
+ input: info,
736
+ output: out,
737
+ kind,
738
+ status,
739
+ savedBytes,
740
+ savedPercent: round1(savedBytes / info.bytes * 100),
741
+ actions,
742
+ candidates,
743
+ backupPath,
744
+ durationMs: Date.now() - started
745
+ };
746
+ }
747
+ function belowMinGain(info, bytes, ctx, sameLossyFormat) {
748
+ const gain = (info.bytes - bytes) / info.bytes * 100;
749
+ const min = sameLossyFormat ? Math.max(ctx.preset.minGainPercent, 10) : ctx.preset.minGainPercent;
750
+ if (bytes >= info.bytes) return `no candidate smaller than original (best: ${fmtBytes(bytes)})`;
751
+ if (gain < min) return `gain ${round1(gain)}% is below the ${min}% threshold${sameLossyFormat ? " for re-encoding an already lossy file" : ""}`;
752
+ return null;
753
+ }
754
+ async function handleSvg(info, ctx, started) {
755
+ const src = await readFile2(info.path, "utf8");
756
+ const outData = optimizeSvg(src);
757
+ const bytes = Buffer.byteLength(outData);
758
+ const candidates = [{ format: "svg", bytes, lossless: true, passed: true }];
759
+ const why = belowMinGain(info, bytes, ctx, false);
760
+ if (why) return kept(info, "svg", why, candidates, [], started);
761
+ const outPath = outputPathFor(info.path, "svg", ctx);
762
+ return finish(info, "svg", outData, { path: outPath, format: "svg", width: info.width, height: info.height, bytes, lossless: true }, candidates, ["svgo"], ctx, started);
763
+ }
764
+ async function handleAnimated(info, ctx, started) {
765
+ const formats = allowedFormats(info, ctx);
766
+ if (!formats.includes("webp")) {
767
+ return kept(info, "animated", `animated input; preset "${ctx.preset.name}" does not allow WebP so it was left as-is`, [], [], started);
768
+ }
769
+ const size = targetSize(info, ctx);
770
+ const actions = [];
771
+ const q = ctx.opts.quality ?? ctx.preset.quality.ui.webp;
772
+ let img = sharp4(info.path, { animated: true });
773
+ if (size.resized) {
774
+ img = img.resize({ width: size.width, height: size.height, fit: "inside", withoutEnlargement: true, kernel: "lanczos3" });
775
+ actions.push(`resize:${size.width}x${size.height}`);
776
+ }
777
+ actions.push("strip-metadata");
778
+ const lossless = ctx.preset.lossless;
779
+ const buf = await (lossless ? img.webp({ lossless: true, effort: 6 }) : img.webp({ quality: q, effort: 4 })).toBuffer();
780
+ const candidates = [{ format: "webp", bytes: buf.length, quality: lossless ? void 0 : q, lossless, passed: true }];
781
+ const why = belowMinGain(info, buf.length, ctx, info.format === "webp" && !lossless);
782
+ if (why) return kept(info, "animated", why, candidates, actions, started);
783
+ actions.push(lossless ? "encode:webp-animated-lossless" : `encode:webp-animated@${q}`);
784
+ const outPath = outputPathFor(info.path, "webp", ctx);
785
+ return finish(
786
+ info,
787
+ "animated",
788
+ buf,
789
+ { path: outPath, format: "webp", width: size.width, height: size.height, bytes: buf.length, quality: lossless ? void 0 : q, lossless },
790
+ candidates,
791
+ actions,
792
+ ctx,
793
+ started
794
+ );
795
+ }
796
+ async function handleRaster(input, ctx, started) {
797
+ let info = input;
798
+ const { preset, opts } = ctx;
799
+ const actions = [];
800
+ const explicitKind = preset.forceKind ?? (opts.kind && opts.kind !== "auto" ? opts.kind : void 0);
801
+ const kind = explicitKind ?? (await classify(info)).kind;
802
+ const size = targetSize(info, ctx);
803
+ let pipeline = sharp4(info.path, { pages: 1 }).rotate();
804
+ if (size.resized) {
805
+ pipeline = pipeline.resize({ width: size.width, height: size.height, fit: "inside", withoutEnlargement: true, kernel: "lanczos3" });
806
+ actions.push(`resize:${size.width}x${size.height}`);
807
+ }
808
+ pipeline = pipeline.toColourspace("srgb");
809
+ pipeline = info.hasAlpha ? pipeline.ensureAlpha() : pipeline.removeAlpha();
810
+ const { data, info: rawInfo } = await pipeline.raw().toBuffer({ resolveWithObject: true });
811
+ let raw = { data, width: rawInfo.width, height: rawInfo.height, channels: rawInfo.channels };
812
+ if (info.hasAlpha && alphaIsOpaque(raw)) {
813
+ raw = stripAlpha(raw);
814
+ info = { ...info, hasAlpha: false };
815
+ actions.push("drop-unused-alpha");
816
+ }
817
+ actions.push("strip-metadata");
818
+ const specs = buildSpecs(info, kind, raw.width * raw.height, ctx);
819
+ if (specs.length === 0) return kept(info, kind, "no encoder allowed for this input under the current preset/format", [], actions, started);
820
+ const floor = preset.floor[kind];
821
+ const reference = opts.fast ? null : await toComparable(fromRaw(raw), raw.width, info.hasAlpha);
822
+ const targetBytes = opts.targetKb ? opts.targetKb * 1024 : void 0;
823
+ const encoded = await Promise.all(
824
+ specs.map(async (spec) => {
825
+ if (spec.lossless) {
826
+ const buf = await encode(raw, spec);
827
+ return { spec, buf, cand: { format: spec.format, bytes: buf.length, lossless: true, ssim: 1, passed: true } };
828
+ }
829
+ return searchQuality(raw, spec, reference, floor, targetBytes);
830
+ })
831
+ );
832
+ const candidates = encoded.map((e) => e.cand);
833
+ const passed = encoded.filter((e) => e.cand.passed);
834
+ if (passed.length === 0) {
835
+ return kept(info, kind, `no candidate met the SSIM floor of ${floor} (try --fast, a lower --preset floor, or --kind photo)`, candidates, actions, started);
836
+ }
837
+ passed.sort((a, b) => a.cand.bytes - b.cand.bytes);
838
+ let best = passed[0];
839
+ if (best.spec.format === "avif") {
840
+ const bestOther = passed.find((e) => e.spec.format !== "avif");
841
+ if (bestOther && best.cand.bytes > bestOther.cand.bytes * (1 - preset.avifMinGain)) best = bestOther;
842
+ }
843
+ const sameLossy = best.spec.format === info.format && !best.cand.lossless && (LOSSY_INPUTS.has(info.format) || info.format === "png") && !size.resized;
844
+ const why = belowMinGain(info, best.cand.bytes, ctx, sameLossy);
845
+ if (why) return kept(info, kind, why, candidates, actions, started);
846
+ actions.push(`encode:${best.spec.format}${best.cand.lossless ? "-lossless" : `@${best.cand.quality}`}`);
847
+ const outPath = outputPathFor(info.path, best.spec.format, ctx);
848
+ if (!opts.rename && extFor(best.spec.format) !== path6.extname(info.path).toLowerCase().replace(".jpeg", ".jpg")) {
849
+ actions.push(`warn:extension-mismatch(${path6.extname(info.path)} contains ${best.spec.format})`);
850
+ }
851
+ const status = targetBytes && best.cand.bytes > targetBytes ? "budget-missed" : "optimized";
852
+ return finish(
853
+ info,
854
+ kind,
855
+ best.buf,
856
+ { path: outPath, format: best.spec.format, width: raw.width, height: raw.height, bytes: best.cand.bytes, quality: best.cand.quality, lossless: best.cand.lossless },
857
+ candidates,
858
+ actions,
859
+ ctx,
860
+ started,
861
+ status
862
+ );
863
+ }
864
+ var MAX_Q = 100;
865
+ var MIN_Q = 1;
866
+ var STEP = 6;
867
+ var RETRIES = 3;
868
+ async function searchQuality(raw, spec, reference, floor, targetBytes) {
869
+ const cache = /* @__PURE__ */ new Map();
870
+ const enc = async (q) => {
871
+ let b = cache.get(q);
872
+ if (!b) {
873
+ b = await encode(raw, { ...spec, quality: q });
874
+ cache.set(q, b);
875
+ }
876
+ return b;
877
+ };
878
+ const measure = async (q) => {
879
+ const buf = await enc(q);
880
+ const ssim2 = reference ? await compareSsim(reference, buf) : void 0;
881
+ const passed = ssim2 === void 0 ? true : ssim2 >= floor;
882
+ return { spec: { ...spec, quality: q }, buf, cand: { format: spec.format, bytes: buf.length, quality: q, ssim: ssim2 === void 0 ? void 0 : Number(ssim2.toFixed(4)), passed } };
883
+ };
884
+ const startQ = Math.min(MAX_Q, Math.max(MIN_Q, spec.quality ?? 80));
885
+ const floorSearch = async (from) => {
886
+ let q = from;
887
+ let r = await measure(q);
888
+ for (let i = 0; i < RETRIES && !r.cand.passed && q < MAX_Q; i++) {
889
+ q = Math.min(MAX_Q, q + STEP);
890
+ r = await measure(q);
891
+ }
892
+ return r;
893
+ };
894
+ if (!targetBytes) return floorSearch(startQ);
895
+ let lo = MIN_Q;
896
+ let hi = Math.min(MAX_Q, startQ + 10);
897
+ let fit = null;
898
+ while (lo <= hi) {
899
+ const mid = Math.floor((lo + hi) / 2);
900
+ const b = await enc(mid);
901
+ if (b.length <= targetBytes) {
902
+ fit = mid;
903
+ lo = mid + 1;
904
+ } else hi = mid - 1;
905
+ }
906
+ const budgeted = await floorSearch(fit ?? MIN_Q);
907
+ if (budgeted.cand.passed) return budgeted;
908
+ const fallback = await floorSearch(startQ);
909
+ return fallback.cand.passed ? fallback : budgeted;
910
+ }
911
+ async function inspect(paths, options = {}) {
912
+ const opts = resolveOptions({ ...options, paths });
913
+ const preset = getPreset(opts.preset);
914
+ const { files, roots } = await discover(paths, opts.cwd);
915
+ const ctx = { opts, preset, roots };
916
+ const out = [];
917
+ for (const f of files) {
918
+ let info = await probe(f);
919
+ let kind;
920
+ let floor = null;
921
+ let candidates = [];
922
+ if (info.format === "svg") {
923
+ kind = "svg";
924
+ candidates = ["svgo"];
925
+ } else if (info.animated) {
926
+ kind = "animated";
927
+ candidates = allowedFormats(info, ctx).includes("webp") ? ["webp-animated"] : [];
928
+ } else {
929
+ const cls = await classify(info);
930
+ if (info.hasAlpha && cls.stats.transparentShare === 0) info = { ...info, hasAlpha: false };
931
+ kind = preset.forceKind ?? (opts.kind && opts.kind !== "auto" ? opts.kind : cls.kind);
932
+ floor = preset.floor[kind];
933
+ const size2 = targetSize(info, ctx);
934
+ candidates = buildSpecs(info, kind, size2.width * size2.height, ctx).map((s) => `${s.format}${s.lossless ? "-lossless" : `@${s.quality}`}`);
935
+ }
936
+ const size = targetSize(info, ctx);
937
+ out.push({ input: info, kind, plan: { targetWidth: size.width, targetHeight: size.height, candidates, floor } });
938
+ }
939
+ return out;
940
+ }
941
+ function fmtBytes(n) {
942
+ if (n < 1024) return `${n} B`;
943
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(n < 10 * 1024 ? 1 : 0)} KB`;
944
+ return `${(n / (1024 * 1024)).toFixed(2)} MB`;
945
+ }
946
+
947
+ // src/config.ts
948
+ import { readFile as readFile3 } from "fs/promises";
949
+ import path7 from "path";
950
+ var CANDIDATES = ["img-subir.config.json", ".img-subirrc.json", ".img-subirrc"];
951
+ async function readIfExists(file) {
952
+ try {
953
+ return await readFile3(file, "utf8");
954
+ } catch (e) {
955
+ if (e.code === "ENOENT" || e.code === "ENOTDIR") return null;
956
+ throw new UsageError(`Cannot read config ${file}: ${e.message}`);
957
+ }
958
+ }
959
+ function parse(file, text) {
960
+ try {
961
+ return JSON.parse(text);
962
+ } catch (e) {
963
+ throw new UsageError(`Invalid JSON in ${file}: ${e.message}`);
964
+ }
965
+ }
966
+ async function loadConfig(cwd) {
967
+ let dir = path7.resolve(cwd);
968
+ for (let depth = 0; depth < 8; depth++) {
969
+ for (const name of CANDIDATES) {
970
+ const file = path7.join(dir, name);
971
+ const text = await readIfExists(file);
972
+ if (text !== null) return { config: parse(file, text), source: file };
973
+ }
974
+ const pkgFile = path7.join(dir, "package.json");
975
+ const pkgText = await readIfExists(pkgFile);
976
+ if (pkgText !== null) {
977
+ const pkg = parse(pkgFile, pkgText);
978
+ if (pkg["img-subir"]) return { config: pkg["img-subir"], source: pkgFile };
979
+ }
980
+ const parent = path7.dirname(dir);
981
+ if (parent === dir) break;
982
+ dir = parent;
983
+ }
984
+ return { config: {} };
985
+ }
986
+
987
+ export {
988
+ UsageError,
989
+ presets,
990
+ presetNames,
991
+ getPreset,
992
+ classify,
993
+ BACKUP_DIR,
994
+ discover,
995
+ probe,
996
+ DEFAULT_REF_GLOBS,
997
+ rewriteRefs,
998
+ VERSION,
999
+ optimize,
1000
+ inspect,
1001
+ fmtBytes,
1002
+ loadConfig
1003
+ };
1004
+ //# sourceMappingURL=chunk-CWZBNFPK.js.map