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.
@@ -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
+ };