bytecut 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +140 -2
- package/dist/audit-PEKVNXKA.js +97 -0
- package/dist/check-BYNAWCDE.js +348 -0
- package/dist/chunk-3DBJKHOD.js +109 -0
- package/dist/chunk-3PF6BBFK.js +490 -0
- package/dist/chunk-4FIB6VNC.js +59 -0
- package/dist/chunk-4PWCQ4NF.js +620 -0
- package/dist/chunk-7DHE25UE.js +319 -0
- package/dist/chunk-AYYKUSRP.js +57 -0
- package/dist/chunk-CSFBJ3UH.js +259 -0
- package/dist/chunk-DJ45NQZD.js +29 -0
- package/dist/chunk-FY53B4NB.js +1439 -0
- package/dist/chunk-IZ4JEL3Q.js +212 -0
- package/dist/chunk-KN5QOB7R.js +186 -0
- package/dist/chunk-LTWUPQDT.js +26 -0
- package/dist/chunk-MV6J66AP.js +21 -0
- package/dist/chunk-P5PFI3AO.js +2170 -0
- package/dist/chunk-PQRBLHCC.js +188 -0
- package/dist/chunk-QOG2F4SF.js +536 -0
- package/dist/chunk-S3FQYUTI.js +59 -0
- package/dist/chunk-TCQFQ354.js +282 -0
- package/dist/chunk-WZ6MMRNB.js +238 -0
- package/dist/chunk-XUBQWBWH.js +382 -0
- package/dist/cli.js +138 -0
- package/dist/compare-MPPGTUC5.js +84 -0
- package/dist/doctor-PWCUEBD5.js +77 -0
- package/dist/explain-TS6XF5TX.js +267 -0
- package/dist/init-CWDWVJ55.js +92 -0
- package/dist/optimize-47YALM3D.js +216 -0
- package/dist/snippet-IIPVF6PS.js +114 -0
- package/dist/tui-CDF23I73.js +1691 -0
- package/package.json +46 -3
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
outputWidth,
|
|
4
|
+
toPosix
|
|
5
|
+
} from "./chunk-QOG2F4SF.js";
|
|
6
|
+
import {
|
|
7
|
+
BUILTIN_POLICY,
|
|
8
|
+
getVideoPreset
|
|
9
|
+
} from "./chunk-3PF6BBFK.js";
|
|
10
|
+
import {
|
|
11
|
+
BytecutError
|
|
12
|
+
} from "./chunk-FY53B4NB.js";
|
|
13
|
+
|
|
14
|
+
// src/core/classify/classify.ts
|
|
15
|
+
var FLAT_COLOR_LIMIT = 64;
|
|
16
|
+
var ICON_MAX_EDGE = 1024;
|
|
17
|
+
var HERO_NAME = /(^|[^a-z])(hero|banner|cover|header|splash|masthead|jumbotron)([^a-z]|$)/i;
|
|
18
|
+
var ICON_NAME = /(^|[^a-z])(icon|logo|favicon|sprite|glyph)([^a-z]|$)/i;
|
|
19
|
+
function baseName(source) {
|
|
20
|
+
const name = source.slice(source.lastIndexOf("/") + 1);
|
|
21
|
+
const dot = name.lastIndexOf(".");
|
|
22
|
+
return dot > 0 ? name.slice(0, dot) : name;
|
|
23
|
+
}
|
|
24
|
+
function classify(source, a) {
|
|
25
|
+
const name = baseName(source);
|
|
26
|
+
const longEdge = Math.max(a.width, a.height);
|
|
27
|
+
const flat = a.sampleSize > 0 && a.colorCount <= FLAT_COLOR_LIMIT;
|
|
28
|
+
if (flat && longEdge <= ICON_MAX_EDGE) {
|
|
29
|
+
const named = ICON_NAME.test(name);
|
|
30
|
+
return {
|
|
31
|
+
preset: "icon",
|
|
32
|
+
confidence: named || a.alphaUsed || longEdge <= 512 ? "high" : "medium",
|
|
33
|
+
reason: `flat art (${a.colorCount} colours in sample${a.alphaUsed ? ", uses transparency" : ""}${named ? `, name "${name}"` : ""})`
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
if (!flat && ICON_NAME.test(name) && longEdge <= 256) {
|
|
37
|
+
return {
|
|
38
|
+
preset: "icon",
|
|
39
|
+
confidence: "medium",
|
|
40
|
+
reason: `small image named "${name}" (${longEdge}px)`
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
if (HERO_NAME.test(name)) {
|
|
44
|
+
return { preset: "hero", confidence: "high", reason: `name "${name}" looks like a hero image` };
|
|
45
|
+
}
|
|
46
|
+
if (a.width >= 1920 && a.width / Math.max(1, a.height) >= 1.6) {
|
|
47
|
+
return {
|
|
48
|
+
preset: "hero",
|
|
49
|
+
confidence: "medium",
|
|
50
|
+
reason: `large wide image (${a.width}x${a.height})`
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
const confident = a.colorCount > FLAT_COLOR_LIMIT * 2;
|
|
54
|
+
return {
|
|
55
|
+
preset: "photo",
|
|
56
|
+
confidence: confident ? "high" : "low",
|
|
57
|
+
reason: confident ? "continuous-tone image" : "ambiguous content; using the conservative preset"
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// src/core/hash.ts
|
|
62
|
+
import { createHash } from "crypto";
|
|
63
|
+
import { createReadStream } from "fs";
|
|
64
|
+
function sha256(data) {
|
|
65
|
+
return createHash("sha256").update(data).digest("hex");
|
|
66
|
+
}
|
|
67
|
+
function sha256File(path) {
|
|
68
|
+
return new Promise((resolve, reject) => {
|
|
69
|
+
const hash = createHash("sha256");
|
|
70
|
+
const stream = createReadStream(path);
|
|
71
|
+
stream.on("error", reject);
|
|
72
|
+
stream.on("data", (chunk) => hash.update(chunk));
|
|
73
|
+
stream.on("end", () => resolve(hash.digest("hex")));
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// src/core/plan/build.ts
|
|
78
|
+
import { posix, relative } from "path";
|
|
79
|
+
function targetWidths(presetWidths, sourceWidth) {
|
|
80
|
+
const widths = presetWidths.filter((w) => w <= sourceWidth);
|
|
81
|
+
const largest = Math.max(...presetWidths);
|
|
82
|
+
if (sourceWidth < largest && !widths.includes(sourceWidth)) widths.push(sourceWidth);
|
|
83
|
+
return [...new Set(widths)].sort((a, b) => a - b);
|
|
84
|
+
}
|
|
85
|
+
function outputWidths(preset, sourceWidth, sourceHeight) {
|
|
86
|
+
if (preset.maxEdge === void 0) return targetWidths(preset.widths, sourceWidth);
|
|
87
|
+
const longest = Math.max(sourceWidth, sourceHeight);
|
|
88
|
+
if (longest <= preset.maxEdge) return [sourceWidth];
|
|
89
|
+
return [Math.max(1, Math.floor(sourceWidth * preset.maxEdge / longest))];
|
|
90
|
+
}
|
|
91
|
+
function formatsFor(preset, analysis, confidence) {
|
|
92
|
+
const ambiguousPng = analysis.format === "png" && confidence === "low";
|
|
93
|
+
const needsPng = analysis.alphaUsed || preset.lossless === true || ambiguousPng;
|
|
94
|
+
const out = preset.formats.map((f) => f === "jpg" && needsPng ? "png" : f);
|
|
95
|
+
return [...new Set(out)];
|
|
96
|
+
}
|
|
97
|
+
function mirroredDir(source, staticRoot) {
|
|
98
|
+
const dir = posix.dirname(source);
|
|
99
|
+
if (staticRoot !== "." && staticRoot !== "") {
|
|
100
|
+
if (dir === staticRoot) return "";
|
|
101
|
+
if (dir.startsWith(`${staticRoot}/`)) return dir.slice(staticRoot.length + 1);
|
|
102
|
+
}
|
|
103
|
+
return dir === "." ? "" : dir;
|
|
104
|
+
}
|
|
105
|
+
var DEFAULT_PUBLIC_PATH = "/_bytecut";
|
|
106
|
+
var ANIMATED_GIF_REASON = "animated GIF";
|
|
107
|
+
async function buildPlan(options) {
|
|
108
|
+
const items = [];
|
|
109
|
+
const claimed = /* @__PURE__ */ new Map();
|
|
110
|
+
const staticRoot = options.staticRoot ?? ".";
|
|
111
|
+
const analyze = options.analyze ?? ((data) => options.engine.analyze(data));
|
|
112
|
+
const files = options.files.map((file) => ({ file, source: toPosix(relative(options.projectRoot, file)) })).sort((a, b) => a.source < b.source ? -1 : a.source > b.source ? 1 : 0);
|
|
113
|
+
for (const { file, source } of files) {
|
|
114
|
+
if (source.startsWith("../") || source === "..") {
|
|
115
|
+
throw new BytecutError(
|
|
116
|
+
"SOURCE_OUTSIDE_PROJECT",
|
|
117
|
+
`${file} is outside the project root (${options.projectRoot}); run bytecut from a parent folder.`
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
const data = await options.readSource(file);
|
|
121
|
+
const digest = sha256(data);
|
|
122
|
+
const base = {
|
|
123
|
+
source,
|
|
124
|
+
sourceBytes: data.byteLength,
|
|
125
|
+
sourceSha256: digest,
|
|
126
|
+
sourceWidth: 0,
|
|
127
|
+
sourceHeight: 0
|
|
128
|
+
};
|
|
129
|
+
let analysis;
|
|
130
|
+
try {
|
|
131
|
+
analysis = await analyze(data, digest);
|
|
132
|
+
} catch {
|
|
133
|
+
items.push({
|
|
134
|
+
...base,
|
|
135
|
+
preset: "-",
|
|
136
|
+
outputs: [],
|
|
137
|
+
skip: { reason: "unreadable or corrupt image" }
|
|
138
|
+
});
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const sized = { ...base, sourceWidth: analysis.width, sourceHeight: analysis.height };
|
|
142
|
+
if (analysis.format === "other") {
|
|
143
|
+
items.push({
|
|
144
|
+
...sized,
|
|
145
|
+
preset: "-",
|
|
146
|
+
outputs: [],
|
|
147
|
+
skip: { reason: "unsupported image format" }
|
|
148
|
+
});
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (analysis.animated) {
|
|
152
|
+
const reason = analysis.format === "gif" ? `${ANIMATED_GIF_REASON} (video conversion arrives with ffmpeg support in M5)` : "animated image";
|
|
153
|
+
items.push({ ...sized, preset: "-", outputs: [], skip: { reason } });
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const choice = options.choosePreset(source, analysis);
|
|
157
|
+
const { preset } = choice;
|
|
158
|
+
const dir = mirroredDir(source, staticRoot);
|
|
159
|
+
const name = posix.basename(source, posix.extname(source));
|
|
160
|
+
const outputs = [];
|
|
161
|
+
for (const width of outputWidths(preset, analysis.width, analysis.height)) {
|
|
162
|
+
for (const format of formatsFor(preset, analysis, choice.confidence)) {
|
|
163
|
+
const lossless = format === "png" || (preset.lossless ?? (analysis.format === "png" && format === "webp"));
|
|
164
|
+
const path = posix.join(dir, name, `${name}-${width}.${format}`);
|
|
165
|
+
const owner = claimed.get(path);
|
|
166
|
+
if (owner !== void 0) {
|
|
167
|
+
throw new BytecutError(
|
|
168
|
+
"OUTPUT_COLLISION",
|
|
169
|
+
`${source} and ${owner} would both write ${path}. Rename one of them.`
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
claimed.set(path, source);
|
|
173
|
+
outputs.push({
|
|
174
|
+
path,
|
|
175
|
+
format,
|
|
176
|
+
width,
|
|
177
|
+
lossless,
|
|
178
|
+
...preset.encoders[format],
|
|
179
|
+
// A lossless output is exact; there is nothing to search for.
|
|
180
|
+
...preset.search && !lossless ? { search: { ...preset.search } } : {}
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
items.push({
|
|
185
|
+
...sized,
|
|
186
|
+
preset: preset.name,
|
|
187
|
+
classification: {
|
|
188
|
+
reason: choice.reason,
|
|
189
|
+
...choice.confidence ? { confidence: choice.confidence } : {}
|
|
190
|
+
},
|
|
191
|
+
outputs
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
version: 1,
|
|
196
|
+
outDir: toPosix(relative(options.projectRoot, options.outDir)) || ".",
|
|
197
|
+
publicPath: options.publicPath ?? DEFAULT_PUBLIC_PATH,
|
|
198
|
+
items
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// src/core/classify/video.ts
|
|
203
|
+
var BACKGROUND_NAME = /(^|[^a-z])(bg|background|backdrop|loop|ambient|hero|banner|header|splash|texture|cover)([^a-z]|$)/i;
|
|
204
|
+
var BACKGROUND_MAX_SECONDS = 45;
|
|
205
|
+
function baseName2(source) {
|
|
206
|
+
const name = source.slice(source.lastIndexOf("/") + 1);
|
|
207
|
+
const dot = name.lastIndexOf(".");
|
|
208
|
+
return dot > 0 ? name.slice(0, dot) : name;
|
|
209
|
+
}
|
|
210
|
+
function classifyVideo(source, probe) {
|
|
211
|
+
const name = baseName2(source);
|
|
212
|
+
const named = BACKGROUND_NAME.test(name);
|
|
213
|
+
const short = probe.durationSec > 0 && probe.durationSec <= BACKGROUND_MAX_SECONDS;
|
|
214
|
+
if (!probe.hasAudio && short) {
|
|
215
|
+
return {
|
|
216
|
+
preset: "background-video",
|
|
217
|
+
confidence: named ? "high" : "medium",
|
|
218
|
+
reason: `silent ${probe.durationSec.toFixed(0)}s clip${named ? `, name "${name}"` : ""}`
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
if (named && short && probe.hasAudio) {
|
|
222
|
+
return {
|
|
223
|
+
preset: "background-video",
|
|
224
|
+
confidence: "medium",
|
|
225
|
+
reason: `name "${name}" and ${probe.durationSec.toFixed(0)}s long; its audio track will be removed`
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
preset: "content-video",
|
|
230
|
+
confidence: probe.hasAudio ? "high" : "low",
|
|
231
|
+
reason: probe.hasAudio ? "has an audio track, so it is treated as content" : "long silent video; keeping resolution and frame rate"
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// src/core/plan/video.ts
|
|
236
|
+
import { stat } from "fs/promises";
|
|
237
|
+
import { posix as posix2, relative as relative2 } from "path";
|
|
238
|
+
function skipped(source, bytes, sha, reason, extra = {}) {
|
|
239
|
+
return {
|
|
240
|
+
source,
|
|
241
|
+
sourceBytes: bytes,
|
|
242
|
+
sourceSha256: sha,
|
|
243
|
+
sourceWidth: 0,
|
|
244
|
+
sourceHeight: 0,
|
|
245
|
+
durationSec: 0,
|
|
246
|
+
fps: 0,
|
|
247
|
+
hasAudio: false,
|
|
248
|
+
videoCodec: "unknown",
|
|
249
|
+
preset: "-",
|
|
250
|
+
outputs: [],
|
|
251
|
+
skip: { reason },
|
|
252
|
+
...extra
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
function plannedVideoOutputs(source, staticRoot, preset, probe) {
|
|
256
|
+
const dir = mirroredDir(source, staticRoot);
|
|
257
|
+
const name = posix2.basename(source, posix2.extname(source));
|
|
258
|
+
const width = outputWidth(probe.width, preset.maxWidth);
|
|
259
|
+
const outputs = [];
|
|
260
|
+
for (const codec of preset.codecs) {
|
|
261
|
+
outputs.push({
|
|
262
|
+
role: "video",
|
|
263
|
+
path: posix2.join(dir, name, `${name}-${width}.${codec}.mp4`),
|
|
264
|
+
codec,
|
|
265
|
+
width,
|
|
266
|
+
crf: preset.crf[codec],
|
|
267
|
+
speed: preset.speed[codec],
|
|
268
|
+
maxWidth: preset.maxWidth,
|
|
269
|
+
maxFps: preset.maxFps,
|
|
270
|
+
audio: preset.audio,
|
|
271
|
+
audioBitrate: preset.audioBitrate,
|
|
272
|
+
...preset.search ? { search: { ...preset.search } } : {}
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
outputs.push({
|
|
276
|
+
role: "poster",
|
|
277
|
+
path: posix2.join(dir, name, `${name}-poster.avif`),
|
|
278
|
+
format: "avif",
|
|
279
|
+
width,
|
|
280
|
+
quality: preset.poster.quality,
|
|
281
|
+
effort: preset.poster.effort,
|
|
282
|
+
at: preset.poster.at
|
|
283
|
+
});
|
|
284
|
+
return outputs;
|
|
285
|
+
}
|
|
286
|
+
async function buildVideoItems(options) {
|
|
287
|
+
const staticRoot = options.staticRoot ?? ".";
|
|
288
|
+
const claimed = options.claimed ?? /* @__PURE__ */ new Set();
|
|
289
|
+
const items = [];
|
|
290
|
+
const files = options.files.map((file) => ({ file, source: toPosix(relative2(options.projectRoot, file)) })).sort((a, b) => a.source < b.source ? -1 : a.source > b.source ? 1 : 0);
|
|
291
|
+
const caps = options.engine ? await options.engine.capabilities() : null;
|
|
292
|
+
for (const { file, source } of files) {
|
|
293
|
+
if (source.startsWith("../") || source === "..") {
|
|
294
|
+
throw new BytecutError(
|
|
295
|
+
"SOURCE_OUTSIDE_PROJECT",
|
|
296
|
+
`${file} is outside the project root (${options.projectRoot}); run bytecut from a parent folder.`
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
const [info, sha] = await Promise.all([stat(file), sha256File(file)]);
|
|
300
|
+
if (!options.engine || !caps) {
|
|
301
|
+
items.push(skipped(source, info.size, sha, `needs ffmpeg: ${options.needsFfmpeg}`));
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
let probe;
|
|
305
|
+
try {
|
|
306
|
+
probe = options.probe ? await options.probe(file, sha) : await options.engine.probe(file);
|
|
307
|
+
} catch {
|
|
308
|
+
items.push(skipped(source, info.size, sha, "could not be read by ffprobe"));
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
if (!probe || probe.width === 0 || probe.height === 0) {
|
|
312
|
+
items.push(skipped(source, info.size, sha, "no video stream"));
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
const media = {
|
|
316
|
+
sourceWidth: probe.width,
|
|
317
|
+
sourceHeight: probe.height,
|
|
318
|
+
durationSec: probe.durationSec,
|
|
319
|
+
fps: probe.fps,
|
|
320
|
+
hasAudio: probe.hasAudio,
|
|
321
|
+
videoCodec: probe.videoCodec
|
|
322
|
+
};
|
|
323
|
+
const policy = options.policy ?? BUILTIN_POLICY;
|
|
324
|
+
const override = options.presetOverrides?.[source];
|
|
325
|
+
const forced = override ? getVideoPreset(override, policy.catalog) : void 0;
|
|
326
|
+
const matched = policy.assetFor(source);
|
|
327
|
+
const byAssets = matched ? getVideoPreset(matched.preset, policy.catalog) : void 0;
|
|
328
|
+
let preset;
|
|
329
|
+
let why;
|
|
330
|
+
if (forced) {
|
|
331
|
+
preset = forced;
|
|
332
|
+
why = { reason: `set to ${forced.name} for this file` };
|
|
333
|
+
} else if (byAssets && matched) {
|
|
334
|
+
preset = byAssets;
|
|
335
|
+
why = {
|
|
336
|
+
reason: `set to ${byAssets.name} by assets "${matched.pattern}"`,
|
|
337
|
+
confidence: "high"
|
|
338
|
+
};
|
|
339
|
+
} else if (posix2.extname(source).toLowerCase() === ".gif") {
|
|
340
|
+
preset = getVideoPreset("gif-to-video", policy.catalog);
|
|
341
|
+
why = { reason: "animated GIF", confidence: "high" };
|
|
342
|
+
} else {
|
|
343
|
+
const verdict = classifyVideo(source, probe);
|
|
344
|
+
preset = getVideoPreset(verdict.preset, policy.catalog);
|
|
345
|
+
why = { reason: verdict.reason, confidence: verdict.confidence };
|
|
346
|
+
}
|
|
347
|
+
const outputs = plannedVideoOutputs(source, staticRoot, preset, probe);
|
|
348
|
+
for (const out of outputs) {
|
|
349
|
+
if (claimed.has(out.path)) {
|
|
350
|
+
throw new BytecutError(
|
|
351
|
+
"OUTPUT_COLLISION",
|
|
352
|
+
`${source} would write ${out.path}, which another source already writes. Rename one of them.`
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
claimed.add(out.path);
|
|
356
|
+
}
|
|
357
|
+
items.push({
|
|
358
|
+
source,
|
|
359
|
+
sourceBytes: info.size,
|
|
360
|
+
sourceSha256: sha,
|
|
361
|
+
...media,
|
|
362
|
+
preset: preset.name,
|
|
363
|
+
classification: why,
|
|
364
|
+
outputs
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
return items;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export {
|
|
371
|
+
classify,
|
|
372
|
+
sha256,
|
|
373
|
+
sha256File,
|
|
374
|
+
outputWidths,
|
|
375
|
+
mirroredDir,
|
|
376
|
+
DEFAULT_PUBLIC_PATH,
|
|
377
|
+
ANIMATED_GIF_REASON,
|
|
378
|
+
buildPlan,
|
|
379
|
+
classifyVideo,
|
|
380
|
+
plannedVideoOutputs,
|
|
381
|
+
buildVideoItems
|
|
382
|
+
};
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
collectDoctorReport,
|
|
4
|
+
formatDoctorReport
|
|
5
|
+
} from "./chunk-PQRBLHCC.js";
|
|
6
|
+
import {
|
|
7
|
+
svgoEngine
|
|
8
|
+
} from "./chunk-LTWUPQDT.js";
|
|
9
|
+
import {
|
|
10
|
+
audit
|
|
11
|
+
} from "./chunk-4PWCQ4NF.js";
|
|
12
|
+
import {
|
|
13
|
+
package_default
|
|
14
|
+
} from "./chunk-AYYKUSRP.js";
|
|
15
|
+
import {
|
|
16
|
+
formatAuditReport
|
|
17
|
+
} from "./chunk-P5PFI3AO.js";
|
|
18
|
+
import "./chunk-3DBJKHOD.js";
|
|
19
|
+
import "./chunk-TCQFQ354.js";
|
|
20
|
+
import {
|
|
21
|
+
sharpImageEngine
|
|
22
|
+
} from "./chunk-CSFBJ3UH.js";
|
|
23
|
+
import "./chunk-XUBQWBWH.js";
|
|
24
|
+
import {
|
|
25
|
+
createVideoEngine,
|
|
26
|
+
discover,
|
|
27
|
+
ffmpegInstallText,
|
|
28
|
+
ffmpegStatus,
|
|
29
|
+
makeScorerFactory,
|
|
30
|
+
noAssetsMessage
|
|
31
|
+
} from "./chunk-KN5QOB7R.js";
|
|
32
|
+
import {
|
|
33
|
+
doctorDeps
|
|
34
|
+
} from "./chunk-S3FQYUTI.js";
|
|
35
|
+
import "./chunk-QOG2F4SF.js";
|
|
36
|
+
import "./chunk-3PF6BBFK.js";
|
|
37
|
+
import "./chunk-IZ4JEL3Q.js";
|
|
38
|
+
import {
|
|
39
|
+
guarded
|
|
40
|
+
} from "./chunk-MV6J66AP.js";
|
|
41
|
+
import "./chunk-FY53B4NB.js";
|
|
42
|
+
|
|
43
|
+
// src/cli/index.ts
|
|
44
|
+
import { defineCommand, runMain } from "citty";
|
|
45
|
+
|
|
46
|
+
// src/cli/interactive.ts
|
|
47
|
+
function isInteractive(env = process.env, stdin = process.stdin, stdout = process.stdout) {
|
|
48
|
+
const ci = env.CI;
|
|
49
|
+
const inCi = ci !== void 0 && ci !== "" && ci !== "0" && ci.toLowerCase() !== "false";
|
|
50
|
+
return Boolean(stdin.isTTY && stdout.isTTY) && !inCi;
|
|
51
|
+
}
|
|
52
|
+
async function runDefault() {
|
|
53
|
+
const found = await discover([]);
|
|
54
|
+
if (found.scanInputs.length === 0) {
|
|
55
|
+
console.error(noAssetsMessage("", found.project));
|
|
56
|
+
process.exitCode = 2;
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (isInteractive()) {
|
|
60
|
+
const { runTui } = await import("./tui-CDF23I73.js");
|
|
61
|
+
await runTui({
|
|
62
|
+
projectRoot: found.projectRoot,
|
|
63
|
+
project: found.project,
|
|
64
|
+
config: found.config,
|
|
65
|
+
inputs: found.scanInputs,
|
|
66
|
+
outDir: found.outDir,
|
|
67
|
+
engine: sharpImageEngine,
|
|
68
|
+
svgEngine: svgoEngine,
|
|
69
|
+
toolVersion: package_default.version,
|
|
70
|
+
ffmpeg: ffmpegStatus(found.config),
|
|
71
|
+
videoEngine: createVideoEngine({}, found.config),
|
|
72
|
+
makeScorer: makeScorerFactory(void 0, found.config),
|
|
73
|
+
needsFfmpeg: ffmpegInstallText(),
|
|
74
|
+
loadDoctor: async () => formatDoctorReport(await collectDoctorReport(doctorDeps()))
|
|
75
|
+
});
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const report = await audit({
|
|
79
|
+
projectRoot: found.projectRoot,
|
|
80
|
+
engine: sharpImageEngine,
|
|
81
|
+
svgEngine: svgoEngine,
|
|
82
|
+
inputs: found.scanInputs,
|
|
83
|
+
outDir: found.outDir,
|
|
84
|
+
staticRoot: found.project.staticRoot,
|
|
85
|
+
ffmpeg: ffmpegStatus(found.config),
|
|
86
|
+
videoEngine: createVideoEngine({}, found.config),
|
|
87
|
+
needsFfmpeg: ffmpegInstallText(),
|
|
88
|
+
policy: found.config.policy,
|
|
89
|
+
exclude: found.config.exclude,
|
|
90
|
+
ignore: found.config.auditIgnore,
|
|
91
|
+
settings: found.config.rules,
|
|
92
|
+
budgets: found.config.budgets,
|
|
93
|
+
// A bare `npx bytecut` in CI should not create folders in the repo.
|
|
94
|
+
persistCache: false
|
|
95
|
+
});
|
|
96
|
+
console.log(formatAuditReport(report));
|
|
97
|
+
console.log(
|
|
98
|
+
"\nNot an interactive terminal. Run `bytecut optimize -y` to write the optimized files."
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// src/cli/index.ts
|
|
103
|
+
var SUBCOMMANDS = [
|
|
104
|
+
"audit",
|
|
105
|
+
"optimize",
|
|
106
|
+
"snippet",
|
|
107
|
+
"compare",
|
|
108
|
+
"explain",
|
|
109
|
+
"init",
|
|
110
|
+
"check",
|
|
111
|
+
"doctor"
|
|
112
|
+
];
|
|
113
|
+
var main = defineCommand({
|
|
114
|
+
meta: {
|
|
115
|
+
name: "bytecut",
|
|
116
|
+
version: package_default.version,
|
|
117
|
+
description: "Audit and optimize images and videos in web projects"
|
|
118
|
+
},
|
|
119
|
+
// Plain `bytecut` (no subcommand): interactive UI on a TTY, audit report otherwise.
|
|
120
|
+
// citty also invokes the parent's `run` after a subcommand finishes, so bail out then, or
|
|
121
|
+
// every subcommand would be followed by a second, unrelated audit of the project.
|
|
122
|
+
run: guarded(async ({ rawArgs }) => {
|
|
123
|
+
const first = rawArgs.find((arg) => !arg.startsWith("-"));
|
|
124
|
+
if (first !== void 0 && SUBCOMMANDS.includes(first)) return;
|
|
125
|
+
await runDefault();
|
|
126
|
+
}),
|
|
127
|
+
subCommands: {
|
|
128
|
+
audit: () => import("./audit-PEKVNXKA.js").then((m) => m.default),
|
|
129
|
+
optimize: () => import("./optimize-47YALM3D.js").then((m) => m.default),
|
|
130
|
+
snippet: () => import("./snippet-IIPVF6PS.js").then((m) => m.default),
|
|
131
|
+
compare: () => import("./compare-MPPGTUC5.js").then((m) => m.default),
|
|
132
|
+
explain: () => import("./explain-TS6XF5TX.js").then((m) => m.default),
|
|
133
|
+
init: () => import("./init-CWDWVJ55.js").then((m) => m.default),
|
|
134
|
+
check: () => import("./check-BYNAWCDE.js").then((m) => m.default),
|
|
135
|
+
doctor: () => import("./doctor-PWCUEBD5.js").then((m) => m.default)
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
await runMain(main);
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
startCompareServer
|
|
4
|
+
} from "./chunk-7DHE25UE.js";
|
|
5
|
+
import {
|
|
6
|
+
MANIFEST_FILE,
|
|
7
|
+
parseManifest
|
|
8
|
+
} from "./chunk-TCQFQ354.js";
|
|
9
|
+
import {
|
|
10
|
+
configArg,
|
|
11
|
+
discover
|
|
12
|
+
} from "./chunk-KN5QOB7R.js";
|
|
13
|
+
import "./chunk-S3FQYUTI.js";
|
|
14
|
+
import {
|
|
15
|
+
toPosix
|
|
16
|
+
} from "./chunk-QOG2F4SF.js";
|
|
17
|
+
import "./chunk-3PF6BBFK.js";
|
|
18
|
+
import {
|
|
19
|
+
guarded
|
|
20
|
+
} from "./chunk-MV6J66AP.js";
|
|
21
|
+
import {
|
|
22
|
+
BytecutError
|
|
23
|
+
} from "./chunk-FY53B4NB.js";
|
|
24
|
+
|
|
25
|
+
// src/cli/commands/compare.ts
|
|
26
|
+
import { readFile } from "fs/promises";
|
|
27
|
+
import { relative, resolve } from "path";
|
|
28
|
+
import { defineCommand } from "citty";
|
|
29
|
+
var compare_default = defineCommand({
|
|
30
|
+
meta: {
|
|
31
|
+
name: "compare",
|
|
32
|
+
description: "Serve a before/after page for one optimized file on localhost (127.0.0.1, random port)"
|
|
33
|
+
},
|
|
34
|
+
args: {
|
|
35
|
+
...configArg,
|
|
36
|
+
port: { type: "string", description: "Port to listen on (default: a free one)" },
|
|
37
|
+
"out-dir": { type: "string", description: "Output folder (default: from the framework)" }
|
|
38
|
+
},
|
|
39
|
+
run: guarded(async ({ args }) => {
|
|
40
|
+
const inputs = args._.map(String);
|
|
41
|
+
if (inputs.length !== 1) {
|
|
42
|
+
throw new BytecutError(
|
|
43
|
+
"BAD_ARGUMENTS",
|
|
44
|
+
"Name one optimized file, e.g. `bytecut compare public/img/hero.jpg`."
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
let port = 0;
|
|
48
|
+
if (args.port !== void 0) {
|
|
49
|
+
port = Number(args.port);
|
|
50
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
51
|
+
throw new BytecutError("BAD_ARGUMENTS", `--port must be 0-65535, not "${args.port}".`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const found = await discover([], args["out-dir"], args.config);
|
|
55
|
+
let text;
|
|
56
|
+
try {
|
|
57
|
+
text = await readFile(resolve(found.projectRoot, found.outDir, MANIFEST_FILE), "utf8");
|
|
58
|
+
} catch {
|
|
59
|
+
throw new BytecutError(
|
|
60
|
+
"INPUT_NOT_FOUND",
|
|
61
|
+
`No manifest at ${found.outDir}/${MANIFEST_FILE}. Run \`bytecut optimize -y\` first.`
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
const source = toPosix(relative(found.projectRoot, resolve(found.projectRoot, inputs[0])));
|
|
65
|
+
const server = await startCompareServer({
|
|
66
|
+
projectRoot: found.projectRoot,
|
|
67
|
+
outDir: found.outDir,
|
|
68
|
+
manifest: parseManifest(text),
|
|
69
|
+
source,
|
|
70
|
+
port
|
|
71
|
+
});
|
|
72
|
+
console.log(`Comparing ${source}`);
|
|
73
|
+
console.log(` ${server.url}`);
|
|
74
|
+
console.log("Local only (127.0.0.1). Press Ctrl-C to stop.");
|
|
75
|
+
await new Promise((done) => {
|
|
76
|
+
process.once("SIGINT", () => done());
|
|
77
|
+
process.once("SIGTERM", () => done());
|
|
78
|
+
});
|
|
79
|
+
await server.close();
|
|
80
|
+
})
|
|
81
|
+
});
|
|
82
|
+
export {
|
|
83
|
+
compare_default as default
|
|
84
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
collectDoctorReport,
|
|
4
|
+
formatDoctorReport
|
|
5
|
+
} from "./chunk-PQRBLHCC.js";
|
|
6
|
+
import {
|
|
7
|
+
doctorDeps
|
|
8
|
+
} from "./chunk-S3FQYUTI.js";
|
|
9
|
+
import {
|
|
10
|
+
resolveConfig
|
|
11
|
+
} from "./chunk-3PF6BBFK.js";
|
|
12
|
+
import {
|
|
13
|
+
isGitIgnored
|
|
14
|
+
} from "./chunk-DJ45NQZD.js";
|
|
15
|
+
import {
|
|
16
|
+
guarded
|
|
17
|
+
} from "./chunk-MV6J66AP.js";
|
|
18
|
+
import {
|
|
19
|
+
BytecutError,
|
|
20
|
+
detectProject,
|
|
21
|
+
loadConfig
|
|
22
|
+
} from "./chunk-FY53B4NB.js";
|
|
23
|
+
|
|
24
|
+
// src/cli/commands/doctor.ts
|
|
25
|
+
import { defineCommand } from "citty";
|
|
26
|
+
import { resolve } from "path";
|
|
27
|
+
var doctor_default = defineCommand({
|
|
28
|
+
meta: {
|
|
29
|
+
name: "doctor",
|
|
30
|
+
description: "Check Node, sharp and ffmpeg, and report what is degraded"
|
|
31
|
+
},
|
|
32
|
+
args: {
|
|
33
|
+
config: {
|
|
34
|
+
type: "string",
|
|
35
|
+
description: "Path to a config file (default: bytecut.config.* in the project)"
|
|
36
|
+
},
|
|
37
|
+
ffmpeg: { type: "string", description: "Path to the ffmpeg binary" },
|
|
38
|
+
ffprobe: { type: "string", description: "Path to the ffprobe binary" },
|
|
39
|
+
ssimulacra2: {
|
|
40
|
+
type: "string",
|
|
41
|
+
description: "Path to a native ssimulacra2 (optional; a WebAssembly scorer is built in)"
|
|
42
|
+
},
|
|
43
|
+
json: { type: "boolean", description: "Print the report as JSON" }
|
|
44
|
+
},
|
|
45
|
+
run: guarded(async ({ args }) => {
|
|
46
|
+
const projectRoot = process.cwd();
|
|
47
|
+
let configError;
|
|
48
|
+
let resolved = await resolveConfig(projectRoot, null);
|
|
49
|
+
try {
|
|
50
|
+
resolved = await resolveConfig(projectRoot, await loadConfig(projectRoot, args.config));
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (!(error instanceof BytecutError)) throw error;
|
|
53
|
+
configError = error.message;
|
|
54
|
+
resolved = { ...resolved, project: await detectProject(projectRoot) };
|
|
55
|
+
}
|
|
56
|
+
const report = await collectDoctorReport(doctorDeps(), {
|
|
57
|
+
ffmpegFlag: args.ffmpeg,
|
|
58
|
+
ffprobeFlag: args.ffprobe,
|
|
59
|
+
ffmpegConfig: resolved.ffmpegPath,
|
|
60
|
+
ffprobeConfig: resolved.ffprobePath,
|
|
61
|
+
ssimulacra2Flag: args.ssimulacra2,
|
|
62
|
+
ssimulacra2Config: resolved.ssimulacra2Path
|
|
63
|
+
});
|
|
64
|
+
report.project = {
|
|
65
|
+
framework: resolved.project.framework,
|
|
66
|
+
configFile: resolved.loaded?.name ?? null,
|
|
67
|
+
...configError ? { configError } : {},
|
|
68
|
+
outDir: resolved.project.outDir,
|
|
69
|
+
outDirIgnored: await isGitIgnored(projectRoot, resolve(projectRoot, resolved.project.outDir))
|
|
70
|
+
};
|
|
71
|
+
console.log(args.json ? JSON.stringify(report, null, 2) : formatDoctorReport(report));
|
|
72
|
+
if (!report.ok) process.exitCode = 1;
|
|
73
|
+
})
|
|
74
|
+
});
|
|
75
|
+
export {
|
|
76
|
+
doctor_default as default
|
|
77
|
+
};
|