bytecut 0.0.0-stage → 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.
- package/dist/audit-HVW4EOLP.js +97 -0
- package/dist/check-YONIQTM7.js +348 -0
- package/dist/chunk-326CQQWX.js +188 -0
- package/dist/chunk-3DBJKHOD.js +109 -0
- package/dist/chunk-4XOB3C34.js +186 -0
- package/dist/chunk-5R7LR5BB.js +282 -0
- package/dist/chunk-7IECTPJB.js +319 -0
- package/dist/chunk-CEZK3RQJ.js +2170 -0
- package/dist/chunk-DJ45NQZD.js +29 -0
- package/dist/chunk-G53LNJ5O.js +57 -0
- package/dist/chunk-GMHOU4Y5.js +59 -0
- package/dist/chunk-J2XJO4JZ.js +21 -0
- package/dist/chunk-LHTI3JMC.js +212 -0
- package/dist/chunk-LTWUPQDT.js +26 -0
- package/dist/chunk-M6ZDSATP.js +59 -0
- package/dist/chunk-N536JFSF.js +614 -0
- package/dist/chunk-NAKV7HS5.js +370 -0
- package/dist/chunk-TWI2OZJZ.js +1432 -0
- package/dist/chunk-U4KJMKI5.js +229 -0
- package/dist/chunk-WE7AEDVI.js +536 -0
- package/dist/chunk-XFJ3HW4X.js +248 -0
- package/dist/chunk-YBEC5QGV.js +463 -0
- package/dist/cli.js +138 -0
- package/dist/compare-6PCE6533.js +84 -0
- package/dist/doctor-R226JU6P.js +77 -0
- package/dist/explain-SGN5BOR7.js +265 -0
- package/dist/init-WHWUANOB.js +92 -0
- package/dist/optimize-UXM6JEDA.js +216 -0
- package/dist/snippet-2WVGMCNV.js +114 -0
- package/dist/tui-J4Y44IL7.js +1691 -0
- package/package.json +46 -3
- package/README.md +0 -3
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
AnalysisCache,
|
|
4
|
+
collectAssets,
|
|
5
|
+
engineFingerprint,
|
|
6
|
+
sortFindings,
|
|
7
|
+
walkFiles,
|
|
8
|
+
withVideos
|
|
9
|
+
} from "./chunk-CEZK3RQJ.js";
|
|
10
|
+
import {
|
|
11
|
+
MANIFEST_FILE,
|
|
12
|
+
parseManifest
|
|
13
|
+
} from "./chunk-5R7LR5BB.js";
|
|
14
|
+
import {
|
|
15
|
+
ANIMATED_GIF_REASON,
|
|
16
|
+
buildPlan,
|
|
17
|
+
classify,
|
|
18
|
+
classifyVideo,
|
|
19
|
+
sha256
|
|
20
|
+
} from "./chunk-NAKV7HS5.js";
|
|
21
|
+
import {
|
|
22
|
+
isFastStart,
|
|
23
|
+
toPosix
|
|
24
|
+
} from "./chunk-WE7AEDVI.js";
|
|
25
|
+
import {
|
|
26
|
+
BUILTIN_POLICY,
|
|
27
|
+
getImagePreset,
|
|
28
|
+
pickAv1Encoder,
|
|
29
|
+
rootPreset
|
|
30
|
+
} from "./chunk-YBEC5QGV.js";
|
|
31
|
+
import {
|
|
32
|
+
SafeFs
|
|
33
|
+
} from "./chunk-LHTI3JMC.js";
|
|
34
|
+
import {
|
|
35
|
+
ALL_RULES,
|
|
36
|
+
checkCache,
|
|
37
|
+
checkVideoCache,
|
|
38
|
+
matchesGlob,
|
|
39
|
+
savingOf
|
|
40
|
+
} from "./chunk-TWI2OZJZ.js";
|
|
41
|
+
|
|
42
|
+
// src/core/commands/audit.ts
|
|
43
|
+
import { stat as stat2 } from "fs/promises";
|
|
44
|
+
import { resolve } from "path";
|
|
45
|
+
|
|
46
|
+
// src/core/audit/estimate.ts
|
|
47
|
+
var TRIAL_MAX_WIDTH = 640;
|
|
48
|
+
var SIZE_EXPONENT = 1.6;
|
|
49
|
+
function estimateKey(sha2562, preset, out, width, encoder) {
|
|
50
|
+
return [
|
|
51
|
+
sha2562,
|
|
52
|
+
preset,
|
|
53
|
+
out.format,
|
|
54
|
+
width,
|
|
55
|
+
out.lossless ? "L" : `q${out.quality}`,
|
|
56
|
+
out.effort,
|
|
57
|
+
encoder
|
|
58
|
+
].join("|");
|
|
59
|
+
}
|
|
60
|
+
async function estimateItem(item, readData, deps) {
|
|
61
|
+
if (item.skip || item.outputs.length === 0) return null;
|
|
62
|
+
const encoders = deps.engine.encoders();
|
|
63
|
+
const perFormat = [];
|
|
64
|
+
const formats = [...new Set(item.outputs.map((o) => o.format))];
|
|
65
|
+
for (const format of formats) {
|
|
66
|
+
const outs = item.outputs.filter((o) => o.format === format).sort((a, b) => a.width - b.width);
|
|
67
|
+
const largest = outs[outs.length - 1];
|
|
68
|
+
const within = outs.filter((o) => o.width <= TRIAL_MAX_WIDTH);
|
|
69
|
+
const trial = within.length > 0 ? within[within.length - 1] : outs[0];
|
|
70
|
+
const key = estimateKey(item.sourceSha256, item.preset, trial, trial.width, encoders[format]);
|
|
71
|
+
let bytes = deps.cache.getEstimate(key)?.bytes;
|
|
72
|
+
if (bytes === void 0) {
|
|
73
|
+
const encoded = await deps.engine.encode(await readData(), trial);
|
|
74
|
+
bytes = encoded.data.byteLength;
|
|
75
|
+
deps.cache.setEstimate(key, { bytes, basis: "exact" });
|
|
76
|
+
}
|
|
77
|
+
const exact = trial.width === largest.width;
|
|
78
|
+
perFormat.push({
|
|
79
|
+
format,
|
|
80
|
+
width: largest.width,
|
|
81
|
+
bytes: exact ? bytes : Math.round(bytes * (largest.width / trial.width) ** SIZE_EXPONENT),
|
|
82
|
+
basis: exact ? "exact" : "extrapolated"
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
const best = perFormat.reduce((a, b) => b.bytes < a.bytes ? b : a);
|
|
86
|
+
return { best, perFormat };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// src/core/scan/references.ts
|
|
90
|
+
import { readFile, stat } from "fs/promises";
|
|
91
|
+
import { basename, extname } from "path";
|
|
92
|
+
var TEXT_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
93
|
+
".html",
|
|
94
|
+
".htm",
|
|
95
|
+
".css",
|
|
96
|
+
".scss",
|
|
97
|
+
".sass",
|
|
98
|
+
".less",
|
|
99
|
+
".js",
|
|
100
|
+
".jsx",
|
|
101
|
+
".mjs",
|
|
102
|
+
".cjs",
|
|
103
|
+
".ts",
|
|
104
|
+
".tsx",
|
|
105
|
+
".mts",
|
|
106
|
+
".vue",
|
|
107
|
+
".svelte",
|
|
108
|
+
".astro",
|
|
109
|
+
".md",
|
|
110
|
+
".mdx",
|
|
111
|
+
".json",
|
|
112
|
+
".yaml",
|
|
113
|
+
".yml",
|
|
114
|
+
".xml",
|
|
115
|
+
".php",
|
|
116
|
+
".twig",
|
|
117
|
+
".liquid",
|
|
118
|
+
".njk",
|
|
119
|
+
".ejs",
|
|
120
|
+
".hbs",
|
|
121
|
+
".erb",
|
|
122
|
+
".txt"
|
|
123
|
+
]);
|
|
124
|
+
var SKIPPED_NAMES = /* @__PURE__ */ new Set(["package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lockb"]);
|
|
125
|
+
var MAX_FILE_BYTES = 1024 * 1024;
|
|
126
|
+
var MAX_TOTAL_BYTES = 64 * 1024 * 1024;
|
|
127
|
+
var IMAGE_REFERENCE = /[\w@%+~.\-/\\]+\.(?:jpe?g|png|webp|avif|gif|svg)\b/gi;
|
|
128
|
+
async function scanReferences(options) {
|
|
129
|
+
const names = /* @__PURE__ */ new Set();
|
|
130
|
+
let filesScanned = 0;
|
|
131
|
+
let total = 0;
|
|
132
|
+
let truncated = false;
|
|
133
|
+
await walkFiles(options.cwd, options, async (path) => {
|
|
134
|
+
if (truncated) return;
|
|
135
|
+
if (!TEXT_EXTENSIONS.has(extname(path).toLowerCase()) || SKIPPED_NAMES.has(basename(path))) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const info = await stat(path).catch(() => null);
|
|
139
|
+
if (!info || info.size > MAX_FILE_BYTES) return;
|
|
140
|
+
if (total + info.size > MAX_TOTAL_BYTES) {
|
|
141
|
+
truncated = true;
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
total += info.size;
|
|
145
|
+
let text;
|
|
146
|
+
try {
|
|
147
|
+
text = await readFile(path, "utf8");
|
|
148
|
+
} catch {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
filesScanned++;
|
|
152
|
+
for (const match of text.matchAll(IMAGE_REFERENCE)) {
|
|
153
|
+
let name = match[0].split(/[\\/]/).pop().toLowerCase();
|
|
154
|
+
try {
|
|
155
|
+
name = decodeURIComponent(name);
|
|
156
|
+
} catch {
|
|
157
|
+
}
|
|
158
|
+
names.add(name);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
return { names, filesScanned, truncated };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// src/core/commands/audit.ts
|
|
165
|
+
import { relative } from "path";
|
|
166
|
+
|
|
167
|
+
// src/core/audit/estimate-video.ts
|
|
168
|
+
var EXACT_MAX_SECONDS = 4;
|
|
169
|
+
var SAMPLE_SECONDS = 1.5;
|
|
170
|
+
var SAMPLE_POINTS = [0.2, 0.5, 0.8];
|
|
171
|
+
function encoderFor(codec, caps) {
|
|
172
|
+
return codec === "h264" ? caps.libx264 ? "libx264" : null : pickAv1Encoder(caps);
|
|
173
|
+
}
|
|
174
|
+
var probeOf = (item) => ({
|
|
175
|
+
durationSec: item.durationSec,
|
|
176
|
+
width: item.sourceWidth,
|
|
177
|
+
height: item.sourceHeight,
|
|
178
|
+
fps: item.fps,
|
|
179
|
+
videoCodec: item.videoCodec,
|
|
180
|
+
pixelFormat: null,
|
|
181
|
+
bitrate: null,
|
|
182
|
+
hasAudio: item.hasAudio,
|
|
183
|
+
audioCodec: null,
|
|
184
|
+
container: "unknown"
|
|
185
|
+
});
|
|
186
|
+
function sampleStarts(durationSec) {
|
|
187
|
+
if (durationSec <= EXACT_MAX_SECONDS) return [{ startSec: 0, durationSec }];
|
|
188
|
+
return SAMPLE_POINTS.map((p) => ({
|
|
189
|
+
startSec: Math.max(
|
|
190
|
+
0,
|
|
191
|
+
Math.min(durationSec - SAMPLE_SECONDS, durationSec * p - SAMPLE_SECONDS / 2)
|
|
192
|
+
),
|
|
193
|
+
durationSec: SAMPLE_SECONDS
|
|
194
|
+
}));
|
|
195
|
+
}
|
|
196
|
+
function audioBytes(out, item) {
|
|
197
|
+
if (out.audio !== "keep" || !item.hasAudio) return 0;
|
|
198
|
+
const kbps = Number(out.audioBitrate.replace(/k$/, ""));
|
|
199
|
+
return Math.round(kbps * 1e3 * item.durationSec / 8);
|
|
200
|
+
}
|
|
201
|
+
async function estimateVideo(item, deps, sourcePath) {
|
|
202
|
+
if (item.skip) return null;
|
|
203
|
+
const caps = await deps.engine.capabilities();
|
|
204
|
+
if (!caps) return null;
|
|
205
|
+
const probe = probeOf(item);
|
|
206
|
+
const starts = sampleStarts(item.durationSec);
|
|
207
|
+
const exact = starts.length === 1 && starts[0].durationSec >= item.durationSec;
|
|
208
|
+
const perCodec = [];
|
|
209
|
+
for (const out of item.outputs) {
|
|
210
|
+
if (out.role !== "video") continue;
|
|
211
|
+
const encoder = encoderFor(out.codec, caps);
|
|
212
|
+
if (!encoder) continue;
|
|
213
|
+
const key = [
|
|
214
|
+
"video",
|
|
215
|
+
item.sourceSha256,
|
|
216
|
+
out.codec,
|
|
217
|
+
encoder,
|
|
218
|
+
out.crf,
|
|
219
|
+
out.speed,
|
|
220
|
+
out.maxWidth ?? "-",
|
|
221
|
+
out.maxFps ?? "-",
|
|
222
|
+
deps.ffmpegVersion,
|
|
223
|
+
exact ? "exact" : "sampled"
|
|
224
|
+
].join("|");
|
|
225
|
+
let streamBytes = deps.cache.getEstimate(key)?.bytes;
|
|
226
|
+
if (streamBytes === void 0) {
|
|
227
|
+
let total = 0;
|
|
228
|
+
let seconds = 0;
|
|
229
|
+
for (const s of starts) {
|
|
230
|
+
total += await deps.engine.sampleBytes(
|
|
231
|
+
{
|
|
232
|
+
source: sourcePath,
|
|
233
|
+
encoder,
|
|
234
|
+
crf: out.crf,
|
|
235
|
+
speed: out.speed,
|
|
236
|
+
maxWidth: out.maxWidth,
|
|
237
|
+
maxFps: out.maxFps,
|
|
238
|
+
startSec: s.startSec,
|
|
239
|
+
durationSec: s.durationSec,
|
|
240
|
+
probe
|
|
241
|
+
},
|
|
242
|
+
deps.signal
|
|
243
|
+
);
|
|
244
|
+
seconds += s.durationSec;
|
|
245
|
+
}
|
|
246
|
+
streamBytes = Math.round(seconds > 0 ? total / seconds * item.durationSec : total);
|
|
247
|
+
deps.cache.setEstimate(key, { bytes: streamBytes, basis: exact ? "exact" : "extrapolated" });
|
|
248
|
+
}
|
|
249
|
+
perCodec.push({
|
|
250
|
+
codec: out.codec,
|
|
251
|
+
bytes: streamBytes + audioBytes(out, item),
|
|
252
|
+
basis: exact ? "exact" : "extrapolated"
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
if (perCodec.length === 0) return null;
|
|
256
|
+
const best = perCodec.reduce((a, b) => b.bytes < a.bytes ? b : a);
|
|
257
|
+
return { best, perCodec };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// src/core/commands/audit.ts
|
|
261
|
+
import { basename as basename2, dirname, extname as extname2 } from "path";
|
|
262
|
+
async function audit(options) {
|
|
263
|
+
const { projectRoot, engine, svgEngine } = options;
|
|
264
|
+
const outDir = resolve(projectRoot, options.outDir);
|
|
265
|
+
const notes = [];
|
|
266
|
+
const found = await collectAssets(options.inputs, {
|
|
267
|
+
cwd: projectRoot,
|
|
268
|
+
outDir,
|
|
269
|
+
...options.exclude ? { exclude: options.exclude } : {}
|
|
270
|
+
});
|
|
271
|
+
const policy = options.policy ?? BUILTIN_POLICY;
|
|
272
|
+
const fs = await SafeFs.create({
|
|
273
|
+
projectRoot,
|
|
274
|
+
outDir,
|
|
275
|
+
sourceFiles: [...found.files, ...found.svgs, ...found.videos],
|
|
276
|
+
sourceDirs: found.sourceDirs
|
|
277
|
+
});
|
|
278
|
+
const videoVersions = options.videoEngine ? await options.videoEngine.versions() : {};
|
|
279
|
+
const cache = await AnalysisCache.load(
|
|
280
|
+
fs,
|
|
281
|
+
`${engineFingerprint(engine, videoVersions)};${svgEngine.version()}`
|
|
282
|
+
);
|
|
283
|
+
const manifestData = await fs.readOutput(MANIFEST_FILE);
|
|
284
|
+
const manifest = manifestData ? parseManifest(manifestData.toString("utf8")) : null;
|
|
285
|
+
const total = found.files.length + found.videos.length;
|
|
286
|
+
let analyzed = 0;
|
|
287
|
+
options.onProgress?.({ phase: "scan", done: 0, total });
|
|
288
|
+
const analyze = async (data, digest) => {
|
|
289
|
+
let result = cache.getAnalysis(digest);
|
|
290
|
+
if (!result) {
|
|
291
|
+
result = await engine.analyze(data);
|
|
292
|
+
cache.setAnalysis(digest, result);
|
|
293
|
+
}
|
|
294
|
+
options.onProgress?.({ phase: "analyze", done: ++analyzed, total });
|
|
295
|
+
return result;
|
|
296
|
+
};
|
|
297
|
+
const imagePlan = await buildPlan({
|
|
298
|
+
files: found.files,
|
|
299
|
+
projectRoot,
|
|
300
|
+
outDir,
|
|
301
|
+
staticRoot: options.staticRoot,
|
|
302
|
+
choosePreset: (source, analysis) => {
|
|
303
|
+
const forced = options.presetOverrides?.[source];
|
|
304
|
+
if (forced) {
|
|
305
|
+
return {
|
|
306
|
+
preset: getImagePreset(forced, policy.catalog),
|
|
307
|
+
reason: `set to ${forced} for this file`
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
const matched = policy.assetFor(source);
|
|
311
|
+
const assetPreset = matched ? policy.catalog.image[matched.preset] : void 0;
|
|
312
|
+
if (matched && assetPreset) {
|
|
313
|
+
return {
|
|
314
|
+
preset: assetPreset,
|
|
315
|
+
reason: `set to ${matched.preset} by assets "${matched.pattern}"`,
|
|
316
|
+
confidence: "high"
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
const verdict = classify(source, analysis);
|
|
320
|
+
return {
|
|
321
|
+
preset: getImagePreset(verdict.preset, policy.catalog),
|
|
322
|
+
reason: verdict.reason,
|
|
323
|
+
confidence: verdict.confidence
|
|
324
|
+
};
|
|
325
|
+
},
|
|
326
|
+
engine,
|
|
327
|
+
analyze,
|
|
328
|
+
readSource: (path) => fs.readSource(path)
|
|
329
|
+
});
|
|
330
|
+
const needsFfmpeg = options.needsFfmpeg ?? "install ffmpeg";
|
|
331
|
+
const plan = await withVideos(imagePlan, {
|
|
332
|
+
projectRoot,
|
|
333
|
+
staticRoot: options.staticRoot,
|
|
334
|
+
videoFiles: found.videos,
|
|
335
|
+
videoEngine: options.videoEngine ?? null,
|
|
336
|
+
needsFfmpeg,
|
|
337
|
+
presetOverrides: options.presetOverrides,
|
|
338
|
+
policy,
|
|
339
|
+
cache
|
|
340
|
+
});
|
|
341
|
+
const outputHash = async (path) => {
|
|
342
|
+
const data = await fs.readOutput(path);
|
|
343
|
+
return data ? sha256(data) : null;
|
|
344
|
+
};
|
|
345
|
+
const absoluteOf = (source) => resolve(projectRoot, ...source.split("/"));
|
|
346
|
+
const images = [];
|
|
347
|
+
const skipped = [];
|
|
348
|
+
let estimated = 0;
|
|
349
|
+
for (const item of plan.items) {
|
|
350
|
+
options.onProgress?.({ phase: "estimate", done: estimated++, total });
|
|
351
|
+
if (item.skip) {
|
|
352
|
+
skipped.push({
|
|
353
|
+
source: item.source,
|
|
354
|
+
bytes: item.sourceBytes,
|
|
355
|
+
reason: item.skip.reason,
|
|
356
|
+
animatedGif: item.skip.reason.startsWith(ANIMATED_GIF_REASON)
|
|
357
|
+
});
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
const data = absoluteOf(item.source);
|
|
361
|
+
const estimate = await estimateItem(item, () => fs.readSource(data), { engine, cache });
|
|
362
|
+
images.push({
|
|
363
|
+
source: item.source,
|
|
364
|
+
bytes: item.sourceBytes,
|
|
365
|
+
sha256: item.sourceSha256,
|
|
366
|
+
// The analysis is cached by hash, so this is a lookup, not a second decode.
|
|
367
|
+
analysis: cache.getAnalysis(item.sourceSha256),
|
|
368
|
+
classification: {
|
|
369
|
+
// Rules reason about the built-in a custom preset descends from.
|
|
370
|
+
preset: rootPreset(policy.catalog, item.preset),
|
|
371
|
+
confidence: item.classification?.confidence ?? "low",
|
|
372
|
+
reason: item.classification?.reason ?? ""
|
|
373
|
+
},
|
|
374
|
+
item,
|
|
375
|
+
cache: await checkCache(item, manifest?.sources[item.source], outputHash),
|
|
376
|
+
estimate
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
const svgs = [];
|
|
380
|
+
for (const path of found.svgs) {
|
|
381
|
+
const buffer = await fs.readSource(path);
|
|
382
|
+
const digest = sha256(buffer);
|
|
383
|
+
let analysis = cache.getSvg(digest);
|
|
384
|
+
if (!analysis) {
|
|
385
|
+
analysis = svgEngine.analyze(buffer.toString("utf8"));
|
|
386
|
+
cache.setSvg(digest, analysis);
|
|
387
|
+
}
|
|
388
|
+
svgs.push({
|
|
389
|
+
source: toPosix(relative(projectRoot, path)),
|
|
390
|
+
bytes: buffer.byteLength,
|
|
391
|
+
sha256: digest,
|
|
392
|
+
...analysis
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
svgs.sort((a, b) => a.source < b.source ? -1 : 1);
|
|
396
|
+
const videoFacts = [];
|
|
397
|
+
const unprobed = [];
|
|
398
|
+
const siblingImages = /* @__PURE__ */ new Set([
|
|
399
|
+
...images.map((i) => i.source),
|
|
400
|
+
...skipped.map((i) => i.source),
|
|
401
|
+
...plan.items.map((i) => i.source)
|
|
402
|
+
]);
|
|
403
|
+
const missingCodecs = /* @__PURE__ */ new Set();
|
|
404
|
+
const caps = options.videoEngine ? await options.videoEngine.capabilities() : null;
|
|
405
|
+
if (caps) {
|
|
406
|
+
if (!caps.libsvtav1 && !caps.libaomAv1) missingCodecs.add("av1");
|
|
407
|
+
if (!caps.libx264) missingCodecs.add("h264");
|
|
408
|
+
}
|
|
409
|
+
let probed = 0;
|
|
410
|
+
for (const item of plan.videos ?? []) {
|
|
411
|
+
options.onProgress?.({ phase: "estimate", done: probed++, total });
|
|
412
|
+
if (item.skip) {
|
|
413
|
+
if (item.skip.reason.startsWith("needs ffmpeg")) {
|
|
414
|
+
unprobed.push({ source: item.source, bytes: item.sourceBytes });
|
|
415
|
+
} else {
|
|
416
|
+
skipped.push({
|
|
417
|
+
source: item.source,
|
|
418
|
+
bytes: item.sourceBytes,
|
|
419
|
+
reason: item.skip.reason,
|
|
420
|
+
animatedGif: false
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
const probe = cache.getVideoProbe(item.sourceSha256);
|
|
426
|
+
if (!probe) continue;
|
|
427
|
+
const absolute = absoluteOf(item.source);
|
|
428
|
+
const cacheState = await checkVideoCache(
|
|
429
|
+
item,
|
|
430
|
+
manifest?.videos[item.source],
|
|
431
|
+
outputHash,
|
|
432
|
+
missingCodecs
|
|
433
|
+
);
|
|
434
|
+
const isGif = extname2(item.source).toLowerCase() === ".gif";
|
|
435
|
+
const covered = cacheState.kind === "hit";
|
|
436
|
+
const estimate = covered || !options.videoEngine ? null : await estimateVideo(
|
|
437
|
+
item,
|
|
438
|
+
{
|
|
439
|
+
engine: options.videoEngine,
|
|
440
|
+
cache,
|
|
441
|
+
ffmpegVersion: videoVersions.ffmpeg ?? "unknown"
|
|
442
|
+
},
|
|
443
|
+
absolute
|
|
444
|
+
);
|
|
445
|
+
const stem = `${dirname(item.source) === "." ? "" : `${dirname(item.source)}/`}${basename2(item.source, extname2(item.source))}`;
|
|
446
|
+
const hasPoster = ["jpg", "jpeg", "png", "webp", "avif"].some(
|
|
447
|
+
(ext) => siblingImages.has(`${stem}.${ext}`) || siblingImages.has(`${stem}-poster.${ext}`) || siblingImages.has(`${stem}_poster.${ext}`)
|
|
448
|
+
);
|
|
449
|
+
videoFacts.push({
|
|
450
|
+
source: item.source,
|
|
451
|
+
bytes: item.sourceBytes,
|
|
452
|
+
sha256: item.sourceSha256,
|
|
453
|
+
probe,
|
|
454
|
+
faststart: isGif ? null : await isFastStart(absolute).catch(() => null),
|
|
455
|
+
classification: isGif ? { preset: "gif-to-video", confidence: "high", reason: "animated GIF" } : {
|
|
456
|
+
// What the plan chose (a per-file or `assets` choice included), as its built-in.
|
|
457
|
+
preset: rootPreset(policy.catalog, item.preset),
|
|
458
|
+
confidence: item.classification?.confidence ?? classifyVideo(item.source, probe).confidence,
|
|
459
|
+
reason: item.classification?.reason ?? classifyVideo(item.source, probe).reason
|
|
460
|
+
},
|
|
461
|
+
item,
|
|
462
|
+
cache: cacheState,
|
|
463
|
+
estimate,
|
|
464
|
+
hasPoster,
|
|
465
|
+
isGif
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
let references = null;
|
|
469
|
+
if (options.scanReferences !== false) {
|
|
470
|
+
const scan = await scanReferences({ cwd: projectRoot, outDir });
|
|
471
|
+
references = scan.names;
|
|
472
|
+
if (scan.truncated) {
|
|
473
|
+
notes.push("Reference scan hit its size limit; project/unreferenced may over-report.");
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
const settings = options.settings ?? {};
|
|
477
|
+
const settingFor = (id) => {
|
|
478
|
+
const raw = settings[id];
|
|
479
|
+
return Array.isArray(raw) ? { severity: raw[0], options: raw[1] } : { severity: raw, options: {} };
|
|
480
|
+
};
|
|
481
|
+
const videoEncoders = {};
|
|
482
|
+
if (caps && videoVersions.ffmpeg) {
|
|
483
|
+
const av1 = caps.libsvtav1 ? "libsvtav1" : caps.libaomAv1 ? "libaom-av1" : null;
|
|
484
|
+
if (av1) videoEncoders.av1 = `${av1} (ffmpeg ${videoVersions.ffmpeg})`;
|
|
485
|
+
if (caps.libx264) videoEncoders.h264 = `libx264 (ffmpeg ${videoVersions.ffmpeg})`;
|
|
486
|
+
}
|
|
487
|
+
const ctx = {
|
|
488
|
+
plan,
|
|
489
|
+
manifest,
|
|
490
|
+
videoEncoders,
|
|
491
|
+
unavailableCodecs: missingCodecs,
|
|
492
|
+
encoders: engine.encoders(),
|
|
493
|
+
outputHash,
|
|
494
|
+
sourceExists: async (source) => (await stat2(absoluteOf(source)).catch(() => null))?.isFile() ?? false,
|
|
495
|
+
images,
|
|
496
|
+
skipped,
|
|
497
|
+
svgs,
|
|
498
|
+
videos: videoFacts,
|
|
499
|
+
unprobed: unprobed.map((u) => ({ ...u, reason: `needs ffmpeg: ${needsFfmpeg}` })),
|
|
500
|
+
references,
|
|
501
|
+
budgets: options.budgets ?? {},
|
|
502
|
+
ffmpeg: options.ffmpeg,
|
|
503
|
+
options: (id) => settingFor(id).options
|
|
504
|
+
};
|
|
505
|
+
const ignorePatterns = options.ignore ?? [];
|
|
506
|
+
const isIgnored = (source) => ignorePatterns.some((p) => matchesGlob(p, source));
|
|
507
|
+
const ignoreSources = (all) => ignorePatterns.length === 0 ? all : all.filter((f) => !f.source || !isIgnored(f.source));
|
|
508
|
+
const findings = [];
|
|
509
|
+
for (const rule of ALL_RULES) {
|
|
510
|
+
const { severity } = settingFor(rule.id);
|
|
511
|
+
if (severity === "off") continue;
|
|
512
|
+
for (const finding of await rule.check(ctx)) {
|
|
513
|
+
findings.push(severity ? { ...finding, severity } : finding);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
if (unprobed.length > 0) {
|
|
517
|
+
notes.push(
|
|
518
|
+
`${unprobed.length} video file${unprobed.length === 1 ? "" : "s"} need ffmpeg to be analysed: ${needsFfmpeg}`
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
if (caps && !caps.libsvtav1 && !caps.libaomAv1 && videoFacts.length > 0) {
|
|
522
|
+
notes.push("No AV1 encoder installed: video estimates use H.264 only.");
|
|
523
|
+
}
|
|
524
|
+
if (options.persistCache !== false) await cache.save(fs);
|
|
525
|
+
const potentialImages = images.reduce(
|
|
526
|
+
(sum, image) => sum + (image.cache.kind === "hit" ? 0 : savingOf(image) ?? 0),
|
|
527
|
+
0
|
|
528
|
+
);
|
|
529
|
+
const potentialVideos = videoFacts.reduce(
|
|
530
|
+
(sum, v) => sum + (v.cache.kind === "hit" ? 0 : Math.max(0, v.bytes - (v.estimate?.best.bytes ?? v.bytes))),
|
|
531
|
+
0
|
|
532
|
+
);
|
|
533
|
+
const potential = potentialImages + potentialVideos;
|
|
534
|
+
const files = [
|
|
535
|
+
...images.map((image) => ({
|
|
536
|
+
source: image.source,
|
|
537
|
+
bytes: image.bytes,
|
|
538
|
+
width: image.analysis.width,
|
|
539
|
+
height: image.analysis.height,
|
|
540
|
+
format: image.analysis.format,
|
|
541
|
+
preset: image.item.preset,
|
|
542
|
+
reason: image.classification.reason,
|
|
543
|
+
covered: image.cache.kind === "hit",
|
|
544
|
+
estimatedBytes: image.estimate?.best.bytes ?? null,
|
|
545
|
+
savingsBytes: savingOf(image) ?? null,
|
|
546
|
+
plannedOutputs: image.item.outputs.length
|
|
547
|
+
})),
|
|
548
|
+
...videoFacts.map((v) => ({
|
|
549
|
+
source: v.source,
|
|
550
|
+
bytes: v.bytes,
|
|
551
|
+
width: v.probe.width,
|
|
552
|
+
height: v.probe.height,
|
|
553
|
+
format: v.isGif ? "gif" : v.probe.videoCodec,
|
|
554
|
+
kind: "video",
|
|
555
|
+
durationSec: v.probe.durationSec,
|
|
556
|
+
preset: v.item.preset,
|
|
557
|
+
reason: v.classification.reason,
|
|
558
|
+
covered: v.cache.kind === "hit",
|
|
559
|
+
estimatedBytes: v.estimate?.best.bytes ?? null,
|
|
560
|
+
savingsBytes: v.estimate ? Math.max(0, v.bytes - v.estimate.best.bytes) : null,
|
|
561
|
+
plannedOutputs: v.item.outputs.length
|
|
562
|
+
})),
|
|
563
|
+
...unprobed.map((u) => ({
|
|
564
|
+
source: u.source,
|
|
565
|
+
bytes: u.bytes,
|
|
566
|
+
width: 0,
|
|
567
|
+
height: 0,
|
|
568
|
+
format: "-",
|
|
569
|
+
kind: "video",
|
|
570
|
+
preset: "-",
|
|
571
|
+
reason: "",
|
|
572
|
+
covered: false,
|
|
573
|
+
estimatedBytes: null,
|
|
574
|
+
savingsBytes: null,
|
|
575
|
+
plannedOutputs: 0,
|
|
576
|
+
skipped: `needs ffmpeg: ${needsFfmpeg}`
|
|
577
|
+
})),
|
|
578
|
+
...plan.items.filter((i) => i.skip).map((i) => ({
|
|
579
|
+
source: i.source,
|
|
580
|
+
bytes: i.sourceBytes,
|
|
581
|
+
width: i.sourceWidth,
|
|
582
|
+
height: i.sourceHeight,
|
|
583
|
+
format: "-",
|
|
584
|
+
preset: "-",
|
|
585
|
+
reason: "",
|
|
586
|
+
covered: false,
|
|
587
|
+
estimatedBytes: null,
|
|
588
|
+
savingsBytes: null,
|
|
589
|
+
plannedOutputs: 0,
|
|
590
|
+
skipped: i.skip.reason
|
|
591
|
+
}))
|
|
592
|
+
].sort((a, b) => a.source < b.source ? -1 : a.source > b.source ? 1 : 0);
|
|
593
|
+
return {
|
|
594
|
+
findings: sortFindings(ignoreSources(findings)),
|
|
595
|
+
unprobed,
|
|
596
|
+
files: files.filter((f) => !isIgnored(f.source)),
|
|
597
|
+
counts: {
|
|
598
|
+
images: images.length,
|
|
599
|
+
svgs: svgs.length,
|
|
600
|
+
videos: videoFacts.length + unprobed.length,
|
|
601
|
+
skipped: skipped.length
|
|
602
|
+
},
|
|
603
|
+
totals: {
|
|
604
|
+
sourceBytes: images.reduce((s, i) => s + i.bytes, 0) + videoFacts.reduce((s, v) => s + v.bytes, 0) + unprobed.reduce((s, u) => s + u.bytes, 0) + skipped.reduce((s, i) => s + i.bytes, 0),
|
|
605
|
+
potentialSavingsBytes: potential,
|
|
606
|
+
optimizedImages: images.filter((i) => i.cache.kind === "hit").length + videoFacts.filter((v) => v.cache.kind === "hit").length
|
|
607
|
+
},
|
|
608
|
+
notes
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
export {
|
|
613
|
+
audit
|
|
614
|
+
};
|