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,2170 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
readVideoCodecString
|
|
4
|
+
} from "./chunk-3DBJKHOD.js";
|
|
5
|
+
import {
|
|
6
|
+
MANIFEST_FILE,
|
|
7
|
+
MANIFEST_VERSION,
|
|
8
|
+
parseManifest,
|
|
9
|
+
serializeManifest
|
|
10
|
+
} from "./chunk-TCQFQ354.js";
|
|
11
|
+
import {
|
|
12
|
+
ANIMATED_GIF_REASON,
|
|
13
|
+
DEFAULT_PUBLIC_PATH,
|
|
14
|
+
buildPlan,
|
|
15
|
+
buildVideoItems,
|
|
16
|
+
classify,
|
|
17
|
+
sha256,
|
|
18
|
+
sha256File
|
|
19
|
+
} from "./chunk-XUBQWBWH.js";
|
|
20
|
+
import {
|
|
21
|
+
AbortedError,
|
|
22
|
+
outputWidth
|
|
23
|
+
} from "./chunk-QOG2F4SF.js";
|
|
24
|
+
import {
|
|
25
|
+
BUILTIN_POLICY,
|
|
26
|
+
IMAGE_PRESETS,
|
|
27
|
+
getImagePreset,
|
|
28
|
+
pickAv1Encoder
|
|
29
|
+
} from "./chunk-3PF6BBFK.js";
|
|
30
|
+
import {
|
|
31
|
+
SafeFs
|
|
32
|
+
} from "./chunk-IZ4JEL3Q.js";
|
|
33
|
+
import {
|
|
34
|
+
BytecutError,
|
|
35
|
+
PROJECT_RULES,
|
|
36
|
+
checkCache,
|
|
37
|
+
checkVideoCache,
|
|
38
|
+
formatBytes,
|
|
39
|
+
percentChange,
|
|
40
|
+
plural,
|
|
41
|
+
recordedByPlannedPath
|
|
42
|
+
} from "./chunk-FY53B4NB.js";
|
|
43
|
+
|
|
44
|
+
// src/core/commands/optimize.ts
|
|
45
|
+
import { stat as stat3 } from "fs/promises";
|
|
46
|
+
import { resolve as resolve5 } from "path";
|
|
47
|
+
|
|
48
|
+
// src/core/audit/run.ts
|
|
49
|
+
function sortFindings(findings) {
|
|
50
|
+
const key = (f) => `${f.ruleId}\0${f.source ?? ""}\0${f.path ?? ""}\0${f.message}`;
|
|
51
|
+
return findings.sort((a, b) => key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0);
|
|
52
|
+
}
|
|
53
|
+
async function runProjectRules(ctx) {
|
|
54
|
+
const all = [];
|
|
55
|
+
for (const rule of PROJECT_RULES) all.push(...await rule.check(ctx));
|
|
56
|
+
return sortFindings(all);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// src/core/cache/store.ts
|
|
60
|
+
var CACHE_DIR = ".bytecut-cache";
|
|
61
|
+
var CACHE_FILE = `${CACHE_DIR}/cache.json`;
|
|
62
|
+
var CACHE_IGNORE_FILE = `${CACHE_DIR}/.gitignore`;
|
|
63
|
+
var SCHEMA = 1;
|
|
64
|
+
var MAX_ANALYSES = 5e3;
|
|
65
|
+
var MAX_ESTIMATES = 2e4;
|
|
66
|
+
function trim(record, max) {
|
|
67
|
+
const keys = Object.keys(record);
|
|
68
|
+
for (const key of keys.slice(0, Math.max(0, keys.length - max))) delete record[key];
|
|
69
|
+
}
|
|
70
|
+
var AnalysisCache = class _AnalysisCache {
|
|
71
|
+
constructor(data) {
|
|
72
|
+
this.data = data;
|
|
73
|
+
}
|
|
74
|
+
data;
|
|
75
|
+
dirty = false;
|
|
76
|
+
static empty(fingerprint) {
|
|
77
|
+
return new _AnalysisCache({
|
|
78
|
+
schema: SCHEMA,
|
|
79
|
+
fingerprint,
|
|
80
|
+
analyses: {},
|
|
81
|
+
svgs: {},
|
|
82
|
+
videos: {},
|
|
83
|
+
estimates: {}
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
static async load(reader, fingerprint) {
|
|
87
|
+
try {
|
|
88
|
+
const raw = await reader.readOutput(CACHE_FILE);
|
|
89
|
+
if (raw) {
|
|
90
|
+
const parsed = JSON.parse(raw.toString("utf8"));
|
|
91
|
+
if (parsed.schema === SCHEMA && parsed.fingerprint === fingerprint && parsed.analyses && parsed.svgs && parsed.videos && parsed.estimates) {
|
|
92
|
+
return new _AnalysisCache(parsed);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
} catch {
|
|
96
|
+
}
|
|
97
|
+
return _AnalysisCache.empty(fingerprint);
|
|
98
|
+
}
|
|
99
|
+
getAnalysis(sha) {
|
|
100
|
+
return this.data.analyses[sha];
|
|
101
|
+
}
|
|
102
|
+
setAnalysis(sha, analysis) {
|
|
103
|
+
this.data.analyses[sha] = analysis;
|
|
104
|
+
this.dirty = true;
|
|
105
|
+
}
|
|
106
|
+
getSvg(sha) {
|
|
107
|
+
return this.data.svgs[sha];
|
|
108
|
+
}
|
|
109
|
+
setSvg(sha, analysis) {
|
|
110
|
+
this.data.svgs[sha] = analysis;
|
|
111
|
+
this.dirty = true;
|
|
112
|
+
}
|
|
113
|
+
getVideoProbe(sha) {
|
|
114
|
+
return this.data.videos[sha];
|
|
115
|
+
}
|
|
116
|
+
setVideoProbe(sha, probe) {
|
|
117
|
+
this.data.videos[sha] = probe;
|
|
118
|
+
this.dirty = true;
|
|
119
|
+
}
|
|
120
|
+
getEstimate(key) {
|
|
121
|
+
return this.data.estimates[key];
|
|
122
|
+
}
|
|
123
|
+
setEstimate(key, entry) {
|
|
124
|
+
this.data.estimates[key] = entry;
|
|
125
|
+
this.dirty = true;
|
|
126
|
+
}
|
|
127
|
+
get isDirty() {
|
|
128
|
+
return this.dirty;
|
|
129
|
+
}
|
|
130
|
+
/** Writes only when something changed, so a warm run leaves the disk untouched. */
|
|
131
|
+
async save(writer) {
|
|
132
|
+
if (!this.dirty) return;
|
|
133
|
+
trim(this.data.analyses, MAX_ANALYSES);
|
|
134
|
+
trim(this.data.svgs, MAX_ANALYSES);
|
|
135
|
+
trim(this.data.videos, MAX_ANALYSES);
|
|
136
|
+
trim(this.data.estimates, MAX_ESTIMATES);
|
|
137
|
+
await writer.writeOutput(CACHE_IGNORE_FILE, Buffer.from("*\n"));
|
|
138
|
+
await writer.writeOutput(CACHE_FILE, Buffer.from(`${JSON.stringify(this.data)}
|
|
139
|
+
`));
|
|
140
|
+
this.dirty = false;
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
// src/core/execute/run.ts
|
|
145
|
+
import { resolve as resolve2 } from "path";
|
|
146
|
+
|
|
147
|
+
// src/core/execute/video.ts
|
|
148
|
+
import { stat } from "fs/promises";
|
|
149
|
+
import { resolve } from "path";
|
|
150
|
+
|
|
151
|
+
// src/core/quality/video-search.ts
|
|
152
|
+
var CLIP_SECONDS = 2;
|
|
153
|
+
var WHOLE_MAX_SECONDS = 6;
|
|
154
|
+
var CLIP_POINTS = [0.2, 0.5, 0.8];
|
|
155
|
+
var FRAMES_PER_CLIP = 4;
|
|
156
|
+
var SCORE_WIDTH = 512;
|
|
157
|
+
var CRF_RANGE = {
|
|
158
|
+
av1: { lo: 10, hi: 63 },
|
|
159
|
+
h264: { lo: 10, hi: 51 }
|
|
160
|
+
};
|
|
161
|
+
var MAX_CONFIRM_BUMPS = 3;
|
|
162
|
+
var CONFIRM_STEP = 2;
|
|
163
|
+
function clipsFor(durationSec) {
|
|
164
|
+
if (durationSec <= WHOLE_MAX_SECONDS) {
|
|
165
|
+
return { clips: [{ startSec: 0, durationSec }], whole: true };
|
|
166
|
+
}
|
|
167
|
+
const clips = CLIP_POINTS.map((p) => ({
|
|
168
|
+
startSec: Math.round(
|
|
169
|
+
Math.max(0, Math.min(durationSec - CLIP_SECONDS, durationSec * p - CLIP_SECONDS / 2)) * 1e3
|
|
170
|
+
) / 1e3,
|
|
171
|
+
durationSec: CLIP_SECONDS
|
|
172
|
+
}));
|
|
173
|
+
return { clips, whole: false };
|
|
174
|
+
}
|
|
175
|
+
var evenDown = (n) => Math.max(2, Math.floor(n / 2) * 2);
|
|
176
|
+
function scoreGeometry(probe, maxWidth) {
|
|
177
|
+
const out = outputWidth(probe.width, maxWidth);
|
|
178
|
+
const width = evenDown(Math.min(SCORE_WIDTH, out));
|
|
179
|
+
const height = evenDown(probe.height * width / Math.max(1, probe.width));
|
|
180
|
+
return { width, height };
|
|
181
|
+
}
|
|
182
|
+
async function findCrf(options) {
|
|
183
|
+
const { lo, hi, target } = options;
|
|
184
|
+
const scores = /* @__PURE__ */ new Map();
|
|
185
|
+
const measure = async (crf) => {
|
|
186
|
+
const known = scores.get(crf);
|
|
187
|
+
if (known !== void 0) return known;
|
|
188
|
+
const score = await options.measure(crf);
|
|
189
|
+
scores.set(crf, score);
|
|
190
|
+
return score;
|
|
191
|
+
};
|
|
192
|
+
const passes = async (crf) => await measure(crf) >= target;
|
|
193
|
+
const start = Math.min(hi, Math.max(lo, options.start));
|
|
194
|
+
let good;
|
|
195
|
+
let bad;
|
|
196
|
+
if (await passes(start)) {
|
|
197
|
+
good = start;
|
|
198
|
+
bad = hi + 1;
|
|
199
|
+
if (start < hi) {
|
|
200
|
+
if (await passes(hi)) good = hi;
|
|
201
|
+
else bad = hi;
|
|
202
|
+
}
|
|
203
|
+
} else {
|
|
204
|
+
bad = start;
|
|
205
|
+
if (start === lo || !await passes(lo)) {
|
|
206
|
+
return { ok: false, bestScore: scores.get(lo) ?? scores.get(start), probes: scores.size };
|
|
207
|
+
}
|
|
208
|
+
good = lo;
|
|
209
|
+
}
|
|
210
|
+
while (bad - good > 1) {
|
|
211
|
+
const mid = Math.floor((good + bad) / 2);
|
|
212
|
+
if (await passes(mid)) good = mid;
|
|
213
|
+
else bad = mid;
|
|
214
|
+
}
|
|
215
|
+
return { ok: true, crf: good, score: scores.get(good), probes: scores.size };
|
|
216
|
+
}
|
|
217
|
+
var mean = (values) => values.reduce((a, b) => a + b, 0) / values.length;
|
|
218
|
+
function createVideoSearcher(request) {
|
|
219
|
+
const { clips, whole } = clipsFor(request.probe.durationSec);
|
|
220
|
+
const { width, height } = scoreGeometry(request.probe, request.maxWidth);
|
|
221
|
+
const references = /* @__PURE__ */ new Map();
|
|
222
|
+
const reference = (index) => {
|
|
223
|
+
let cached = references.get(index);
|
|
224
|
+
if (!cached) {
|
|
225
|
+
const clip = clips[index];
|
|
226
|
+
cached = request.engine.frames(
|
|
227
|
+
{
|
|
228
|
+
...clip,
|
|
229
|
+
source: request.source,
|
|
230
|
+
count: FRAMES_PER_CLIP,
|
|
231
|
+
width,
|
|
232
|
+
height,
|
|
233
|
+
pipeline: { probe: request.probe, maxWidth: request.maxWidth, maxFps: request.maxFps }
|
|
234
|
+
},
|
|
235
|
+
request.signal
|
|
236
|
+
);
|
|
237
|
+
references.set(index, cached);
|
|
238
|
+
}
|
|
239
|
+
return cached;
|
|
240
|
+
};
|
|
241
|
+
async function scoreFrames(ref, got) {
|
|
242
|
+
const n = Math.min(ref.length, got.length);
|
|
243
|
+
if (n === 0 || Math.abs(ref.length - got.length) > 1) {
|
|
244
|
+
throw new Error(`compared ${ref.length} reference and ${got.length} encoded frames`);
|
|
245
|
+
}
|
|
246
|
+
const scorer = request.scorer;
|
|
247
|
+
if (!scorer) throw new Error("ssimulacra2 needs a scorer");
|
|
248
|
+
const scores = [];
|
|
249
|
+
for (let i = 0; i < n; i++) scores.push(await scorer.score(ref[i], got[i]));
|
|
250
|
+
return mean(scores);
|
|
251
|
+
}
|
|
252
|
+
const sample = (clip, crf) => ({
|
|
253
|
+
source: request.source,
|
|
254
|
+
encoder: request.encoder,
|
|
255
|
+
crf,
|
|
256
|
+
speed: request.speed,
|
|
257
|
+
maxWidth: request.maxWidth,
|
|
258
|
+
maxFps: request.maxFps,
|
|
259
|
+
...clip,
|
|
260
|
+
probe: request.probe
|
|
261
|
+
});
|
|
262
|
+
async function measure(crf) {
|
|
263
|
+
const perClip = await Promise.all(
|
|
264
|
+
clips.map(async (clip, i) => {
|
|
265
|
+
if (request.metric === "vmaf") {
|
|
266
|
+
return request.engine.vmaf(sample(clip, crf), request.signal);
|
|
267
|
+
}
|
|
268
|
+
const got = await request.engine.encodedFrames(
|
|
269
|
+
{ ...sample(clip, crf), count: FRAMES_PER_CLIP, width, height },
|
|
270
|
+
request.signal
|
|
271
|
+
);
|
|
272
|
+
return scoreFrames(await reference(i), got);
|
|
273
|
+
})
|
|
274
|
+
);
|
|
275
|
+
const score = Math.min(...perClip);
|
|
276
|
+
request.onProbe?.(crf, score);
|
|
277
|
+
return score;
|
|
278
|
+
}
|
|
279
|
+
return {
|
|
280
|
+
whole,
|
|
281
|
+
async find() {
|
|
282
|
+
const range = CRF_RANGE[request.codec];
|
|
283
|
+
return findCrf({
|
|
284
|
+
start: request.startCrf,
|
|
285
|
+
lo: range.lo,
|
|
286
|
+
hi: range.hi,
|
|
287
|
+
target: request.target,
|
|
288
|
+
measure
|
|
289
|
+
});
|
|
290
|
+
},
|
|
291
|
+
async confirm(outputPath, crf) {
|
|
292
|
+
const perClip = await Promise.all(
|
|
293
|
+
clips.map(async (clip, i) => {
|
|
294
|
+
if (request.metric === "vmaf") {
|
|
295
|
+
return request.engine.vmaf(
|
|
296
|
+
{ ...sample(clip, crf), candidate: outputPath },
|
|
297
|
+
request.signal
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
const got = await request.engine.frames(
|
|
301
|
+
{ ...clip, source: outputPath, count: FRAMES_PER_CLIP, width, height, pipeline: null },
|
|
302
|
+
request.signal
|
|
303
|
+
);
|
|
304
|
+
return scoreFrames(await reference(i), got);
|
|
305
|
+
})
|
|
306
|
+
);
|
|
307
|
+
return Math.min(...perClip);
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// src/core/execute/video.ts
|
|
313
|
+
var message = (error) => error instanceof Error ? error.message : String(error);
|
|
314
|
+
function encoderFor(codec, caps) {
|
|
315
|
+
if (codec === "h264") return caps.libx264 ? "libx264" : null;
|
|
316
|
+
return pickAv1Encoder({ ...caps, libvpxVp9: false, libvmaf: false });
|
|
317
|
+
}
|
|
318
|
+
function probeFromItem(item) {
|
|
319
|
+
return {
|
|
320
|
+
durationSec: item.durationSec,
|
|
321
|
+
width: item.sourceWidth,
|
|
322
|
+
height: item.sourceHeight,
|
|
323
|
+
fps: item.fps,
|
|
324
|
+
videoCodec: item.videoCodec,
|
|
325
|
+
pixelFormat: null,
|
|
326
|
+
bitrate: null,
|
|
327
|
+
hasAudio: item.hasAudio,
|
|
328
|
+
audioCodec: null,
|
|
329
|
+
container: "unknown"
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
var evenHeight = (probe, width) => Math.max(2, Math.round(probe.height * width / Math.max(1, probe.width) / 2) * 2);
|
|
333
|
+
function sameParams(planned, r) {
|
|
334
|
+
if (planned.role !== r.role || planned.width !== r.width) return false;
|
|
335
|
+
if (planned.role === "video") {
|
|
336
|
+
const search = planned.search;
|
|
337
|
+
const sameSearch = search === void 0 ? r.search === void 0 && r.crf === planned.crf : r.search?.metric === search.metric && r.search.target === search.target;
|
|
338
|
+
return sameSearch && r.codec === planned.codec && r.speed === planned.speed && r.maxWidth === planned.maxWidth && r.maxFps === planned.maxFps && r.audio === planned.audio;
|
|
339
|
+
}
|
|
340
|
+
return r.quality === planned.quality && r.effort === planned.effort && r.at === planned.at;
|
|
341
|
+
}
|
|
342
|
+
async function runVideoItem(item, ctx) {
|
|
343
|
+
const base = {
|
|
344
|
+
source: item.source,
|
|
345
|
+
sourceBytes: item.sourceBytes,
|
|
346
|
+
sourceSha256: item.sourceSha256,
|
|
347
|
+
sourceWidth: item.sourceWidth,
|
|
348
|
+
sourceHeight: item.sourceHeight,
|
|
349
|
+
durationSec: item.durationSec,
|
|
350
|
+
preset: item.preset
|
|
351
|
+
};
|
|
352
|
+
if (item.skip) return { ...base, status: "skipped", reason: item.skip.reason, outputs: [] };
|
|
353
|
+
const caps = await ctx.engine.capabilities();
|
|
354
|
+
if (!caps) {
|
|
355
|
+
return { ...base, status: "skipped", reason: "needs ffmpeg", outputs: [] };
|
|
356
|
+
}
|
|
357
|
+
const sourcePath = resolve(ctx.projectRoot, ...item.source.split("/"));
|
|
358
|
+
let digest;
|
|
359
|
+
try {
|
|
360
|
+
digest = await sha256File(sourcePath);
|
|
361
|
+
} catch (error) {
|
|
362
|
+
return { ...base, status: "failed", reason: message(error), outputs: [] };
|
|
363
|
+
}
|
|
364
|
+
if (digest !== item.sourceSha256) {
|
|
365
|
+
return {
|
|
366
|
+
...base,
|
|
367
|
+
status: "failed",
|
|
368
|
+
reason: "source changed since the plan was created; re-run to build a fresh plan",
|
|
369
|
+
outputs: []
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
const hashOutput = async (path) => {
|
|
373
|
+
try {
|
|
374
|
+
return await sha256File(await ctx.fs.outputPath(path));
|
|
375
|
+
} catch (error) {
|
|
376
|
+
if (error.code === "ENOENT") return null;
|
|
377
|
+
throw error;
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
const unavailable = /* @__PURE__ */ new Set();
|
|
381
|
+
for (const codec of ["av1", "h264"]) {
|
|
382
|
+
if (!encoderFor(codec, caps)) unavailable.add(codec);
|
|
383
|
+
}
|
|
384
|
+
const entry = ctx.manifest?.videos[item.source];
|
|
385
|
+
const recorded = new Map((entry?.outputs ?? []).map((o) => [o.path, o]));
|
|
386
|
+
const sameSource = entry?.sha256 === item.sourceSha256;
|
|
387
|
+
const probe = probeFromItem(item);
|
|
388
|
+
const versions = await ctx.engine.versions();
|
|
389
|
+
const encoderLabel = (name) => `${name} (ffmpeg ${versions.ffmpeg ?? "unknown"})`;
|
|
390
|
+
const outputs = [];
|
|
391
|
+
const record = (output) => {
|
|
392
|
+
outputs.push(output);
|
|
393
|
+
ctx.onOutputDone?.(item.source, output);
|
|
394
|
+
};
|
|
395
|
+
let encodedAny = false;
|
|
396
|
+
let failed = false;
|
|
397
|
+
const searchable = (metric) => metric === "vmaf" ? caps.libvmaf : ctx.quality !== void 0;
|
|
398
|
+
const retrySearch = /* @__PURE__ */ new Set();
|
|
399
|
+
for (const r of entry?.outputs ?? []) {
|
|
400
|
+
if (r.search && r.score === void 0 && searchable(r.search.metric)) retrySearch.add(r.path);
|
|
401
|
+
}
|
|
402
|
+
const noForce = !ctx.force;
|
|
403
|
+
if (noForce && retrySearch.size === 0) {
|
|
404
|
+
const state = await checkVideoCache(item, entry, hashOutput, unavailable);
|
|
405
|
+
if (state.kind === "hit") {
|
|
406
|
+
for (const planned of item.outputs) {
|
|
407
|
+
const r = recorded.get(planned.path);
|
|
408
|
+
if (!r) {
|
|
409
|
+
if (planned.role === "video") {
|
|
410
|
+
record(unavailableResult(planned, ctx));
|
|
411
|
+
}
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
record(fromRecorded(r));
|
|
415
|
+
}
|
|
416
|
+
return { ...base, status: "cached", outputs };
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
for (const planned of item.outputs) {
|
|
420
|
+
if (ctx.signal?.aborted) {
|
|
421
|
+
record(blank(planned, "cancelled"));
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
const prior = recorded.get(planned.path);
|
|
425
|
+
if (noForce && sameSource && prior && prior.status === "written" && !retrySearch.has(planned.path) && sameParams(planned, prior)) {
|
|
426
|
+
const actual = await hashOutput(planned.path);
|
|
427
|
+
if (actual !== null && actual === prior.sha256) {
|
|
428
|
+
record(fromRecorded(prior));
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
try {
|
|
433
|
+
if (planned.role === "poster") {
|
|
434
|
+
const result2 = await makePoster(planned, sourcePath, probe, ctx, encoderLabel);
|
|
435
|
+
record(result2.output);
|
|
436
|
+
if (result2.wrote) encodedAny = true;
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
const encoder = encoderFor(planned.codec, caps);
|
|
440
|
+
if (!encoder) {
|
|
441
|
+
record(unavailableResult(planned, ctx));
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
const result = await makeVideo(
|
|
445
|
+
planned,
|
|
446
|
+
item,
|
|
447
|
+
sourcePath,
|
|
448
|
+
probe,
|
|
449
|
+
encoder,
|
|
450
|
+
caps.libvmaf,
|
|
451
|
+
ctx,
|
|
452
|
+
encoderLabel
|
|
453
|
+
);
|
|
454
|
+
record(result.output);
|
|
455
|
+
if (result.wrote) encodedAny = true;
|
|
456
|
+
if (result.output.status === "failed" || result.output.status === "unmet") failed = true;
|
|
457
|
+
} catch (error) {
|
|
458
|
+
if (error instanceof AbortedError || ctx.signal?.aborted) {
|
|
459
|
+
record(blank(planned, "cancelled"));
|
|
460
|
+
} else {
|
|
461
|
+
failed = true;
|
|
462
|
+
record({ ...blank(planned, "failed"), error: message(error) });
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
const cancelled = outputs.some((o) => o.status === "cancelled");
|
|
467
|
+
const allReused = !encodedAny && !failed && !cancelled;
|
|
468
|
+
return {
|
|
469
|
+
...base,
|
|
470
|
+
status: failed ? "failed" : cancelled ? "cancelled" : allReused ? "cached" : "done",
|
|
471
|
+
outputs
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
function unavailableResult(planned, ctx) {
|
|
475
|
+
const encoders = planned.codec === "av1" ? "libsvtav1 or libaom-av1" : "libx264";
|
|
476
|
+
ctx.warnings.add(
|
|
477
|
+
`${planned.codec === "av1" ? "AV1" : "H.264"} encoder not available (needs ${encoders}): ${planned.codec === "av1" ? "AV1" : "H.264"} outputs were skipped`
|
|
478
|
+
);
|
|
479
|
+
return { ...blank(planned, "unavailable"), codec: planned.codec };
|
|
480
|
+
}
|
|
481
|
+
function blank(planned, status) {
|
|
482
|
+
return {
|
|
483
|
+
path: planned.path,
|
|
484
|
+
role: planned.role,
|
|
485
|
+
format: planned.role === "video" ? "mp4" : "avif",
|
|
486
|
+
...planned.role === "video" ? { codec: planned.codec } : {},
|
|
487
|
+
width: planned.width,
|
|
488
|
+
height: 0,
|
|
489
|
+
bytes: 0,
|
|
490
|
+
status
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
function fromRecorded(r) {
|
|
494
|
+
return {
|
|
495
|
+
path: r.path,
|
|
496
|
+
role: r.role,
|
|
497
|
+
format: r.format,
|
|
498
|
+
...r.codec ? { codec: r.codec } : {},
|
|
499
|
+
width: r.width,
|
|
500
|
+
height: r.height,
|
|
501
|
+
...r.fps !== void 0 ? { fps: r.fps } : {},
|
|
502
|
+
bytes: r.bytes,
|
|
503
|
+
...r.sha256 ? { sha256: r.sha256 } : {},
|
|
504
|
+
...r.crf !== void 0 ? { crf: r.crf } : {},
|
|
505
|
+
...r.speed !== void 0 ? { speed: r.speed } : {},
|
|
506
|
+
...r.maxWidth !== void 0 ? { maxWidth: r.maxWidth } : {},
|
|
507
|
+
...r.maxFps !== void 0 ? { maxFps: r.maxFps } : {},
|
|
508
|
+
...r.audio !== void 0 ? { audio: r.audio } : {},
|
|
509
|
+
...r.quality !== void 0 ? { quality: r.quality } : {},
|
|
510
|
+
...r.effort !== void 0 ? { effort: r.effort } : {},
|
|
511
|
+
...r.at !== void 0 ? { at: r.at } : {},
|
|
512
|
+
...r.codecs ? { codecs: r.codecs } : {},
|
|
513
|
+
...r.search ? { search: r.search } : {},
|
|
514
|
+
...r.score !== void 0 ? { score: r.score } : {},
|
|
515
|
+
...r.scorer ? { scorer: r.scorer } : {},
|
|
516
|
+
encoder: r.encoder,
|
|
517
|
+
status: r.status === "already-optimal" ? "already-optimal" : "unchanged"
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
async function existingPolicy(path, newHash, ctx) {
|
|
521
|
+
let existing = null;
|
|
522
|
+
try {
|
|
523
|
+
existing = await sha256File(await ctx.fs.outputPath(path));
|
|
524
|
+
} catch (error) {
|
|
525
|
+
if (error.code !== "ENOENT") throw error;
|
|
526
|
+
}
|
|
527
|
+
if (existing === null) return null;
|
|
528
|
+
if (existing === newHash) return "unchanged";
|
|
529
|
+
if (!ctx.force && ctx.owned.get(path) !== existing) {
|
|
530
|
+
ctx.warnings.add(
|
|
531
|
+
`${path}: a file is already there that bytecut did not write or that was edited; left alone (use --force to overwrite)`
|
|
532
|
+
);
|
|
533
|
+
return "protected";
|
|
534
|
+
}
|
|
535
|
+
return null;
|
|
536
|
+
}
|
|
537
|
+
var SEARCH_SHARE = 0.4;
|
|
538
|
+
var EXPECTED_PROBES = 7;
|
|
539
|
+
async function searchCrf(planned, item, sourcePath, probe, encoder, hasLibvmaf, ctx) {
|
|
540
|
+
const search = planned.search;
|
|
541
|
+
if (!search) return { kind: "fixed", skipped: false };
|
|
542
|
+
const skip = (why) => {
|
|
543
|
+
ctx.warnings.add(`${planned.path}: ${why}; used the fixed CRF ${planned.crf}`);
|
|
544
|
+
return { kind: "fixed", skipped: true };
|
|
545
|
+
};
|
|
546
|
+
let scorer;
|
|
547
|
+
if (search.metric === "vmaf") {
|
|
548
|
+
if (!hasLibvmaf) return skip("libvmaf is missing, so the VMAF search was skipped");
|
|
549
|
+
} else {
|
|
550
|
+
if (!ctx.quality) return skip("no quality scorer is available, so the search was skipped");
|
|
551
|
+
try {
|
|
552
|
+
scorer = await ctx.quality.scorer();
|
|
553
|
+
} catch (error) {
|
|
554
|
+
return skip(`the quality scorer could not start (${message(error)})`);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
const { width, height } = scoreGeometry(probe, planned.maxWidth);
|
|
558
|
+
if (search.metric === "ssimulacra2" && (width < 8 || height < 8)) {
|
|
559
|
+
return skip("the video is too small to score");
|
|
560
|
+
}
|
|
561
|
+
const searcher = createVideoSearcher({
|
|
562
|
+
engine: ctx.engine,
|
|
563
|
+
metric: search.metric,
|
|
564
|
+
target: search.target,
|
|
565
|
+
scorer,
|
|
566
|
+
source: sourcePath,
|
|
567
|
+
probe,
|
|
568
|
+
codec: planned.codec,
|
|
569
|
+
encoder,
|
|
570
|
+
speed: planned.speed,
|
|
571
|
+
maxWidth: planned.maxWidth,
|
|
572
|
+
maxFps: planned.maxFps,
|
|
573
|
+
startCrf: planned.crf,
|
|
574
|
+
signal: ctx.signal,
|
|
575
|
+
onProbe: () => {
|
|
576
|
+
probes++;
|
|
577
|
+
ctx.onProgress?.(
|
|
578
|
+
item.source,
|
|
579
|
+
planned.path,
|
|
580
|
+
SEARCH_SHARE * Math.min(1, probes / EXPECTED_PROBES)
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
});
|
|
584
|
+
let probes = 0;
|
|
585
|
+
const outcome = await searcher.find();
|
|
586
|
+
if (!outcome.ok) {
|
|
587
|
+
return {
|
|
588
|
+
kind: "unmet",
|
|
589
|
+
reason: `${search.metric} reached only ${outcome.bestScore.toFixed(1)} at crf ${CRF_RANGE[planned.codec].lo}, below the target ${search.target}`
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
return {
|
|
593
|
+
kind: "found",
|
|
594
|
+
crf: outcome.crf,
|
|
595
|
+
score: outcome.score,
|
|
596
|
+
scorer: search.metric === "vmaf" ? "libvmaf" : scorer.kind,
|
|
597
|
+
searcher
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
async function makeVideo(planned, item, sourcePath, probe, encoder, hasLibvmaf, ctx, encoderLabel) {
|
|
601
|
+
const searched = await searchCrf(planned, item, sourcePath, probe, encoder, hasLibvmaf, ctx);
|
|
602
|
+
if (searched.kind === "unmet") {
|
|
603
|
+
ctx.warnings.add(`${planned.path}: ${searched.reason}; nothing was written`);
|
|
604
|
+
return {
|
|
605
|
+
output: {
|
|
606
|
+
...blank(planned, "unmet"),
|
|
607
|
+
codec: planned.codec,
|
|
608
|
+
error: searched.reason
|
|
609
|
+
},
|
|
610
|
+
wrote: false
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
const found = searched.kind === "found" ? searched : null;
|
|
614
|
+
const range = CRF_RANGE[planned.codec];
|
|
615
|
+
let crf = found ? found.crf : planned.crf;
|
|
616
|
+
let score = found?.score;
|
|
617
|
+
const base = found ? SEARCH_SHARE : 0;
|
|
618
|
+
const temp = await ctx.fs.reserveTemp(planned.path);
|
|
619
|
+
try {
|
|
620
|
+
for (let bumps = 0; ; bumps++) {
|
|
621
|
+
await ctx.engine.encode(
|
|
622
|
+
{
|
|
623
|
+
source: sourcePath,
|
|
624
|
+
output: temp,
|
|
625
|
+
encoder,
|
|
626
|
+
crf,
|
|
627
|
+
speed: planned.speed,
|
|
628
|
+
maxWidth: planned.maxWidth,
|
|
629
|
+
maxFps: planned.maxFps,
|
|
630
|
+
audio: planned.audio,
|
|
631
|
+
audioBitrate: planned.audioBitrate,
|
|
632
|
+
probe
|
|
633
|
+
},
|
|
634
|
+
(fraction) => ctx.onProgress?.(item.source, planned.path, base + (1 - base) * fraction),
|
|
635
|
+
ctx.signal ?? new AbortController().signal
|
|
636
|
+
);
|
|
637
|
+
if (!found || found.searcher.whole) break;
|
|
638
|
+
const measured = await found.searcher.confirm(temp, crf);
|
|
639
|
+
if (measured >= planned.search.target) {
|
|
640
|
+
score = measured;
|
|
641
|
+
break;
|
|
642
|
+
}
|
|
643
|
+
if (bumps >= MAX_CONFIRM_BUMPS || crf - CONFIRM_STEP < range.lo) {
|
|
644
|
+
await ctx.fs.discardTemp(temp);
|
|
645
|
+
const reason = `${planned.search.metric} was ${measured.toFixed(1)} on the full encode at crf ${crf}, below the target ${planned.search.target}`;
|
|
646
|
+
ctx.warnings.add(`${planned.path}: ${reason}; nothing was written`);
|
|
647
|
+
return {
|
|
648
|
+
output: { ...blank(planned, "unmet"), codec: planned.codec, error: reason },
|
|
649
|
+
wrote: false
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
crf -= CONFIRM_STEP;
|
|
653
|
+
}
|
|
654
|
+
const { size } = await stat(temp);
|
|
655
|
+
const video = await readVideoCodecString(temp).catch(() => null);
|
|
656
|
+
const codecs = video ? planned.audio === "keep" && probe.hasAudio ? `${video}, mp4a.40.2` : video : void 0;
|
|
657
|
+
const fps = planned.maxFps !== null ? Math.min(probe.fps, planned.maxFps) : probe.fps;
|
|
658
|
+
const result = {
|
|
659
|
+
path: planned.path,
|
|
660
|
+
role: "video",
|
|
661
|
+
format: "mp4",
|
|
662
|
+
codec: planned.codec,
|
|
663
|
+
width: planned.width,
|
|
664
|
+
height: evenHeight(probe, planned.width),
|
|
665
|
+
fps: Math.round(fps * 100) / 100,
|
|
666
|
+
bytes: size,
|
|
667
|
+
crf,
|
|
668
|
+
speed: planned.speed,
|
|
669
|
+
maxWidth: planned.maxWidth,
|
|
670
|
+
maxFps: planned.maxFps,
|
|
671
|
+
audio: planned.audio,
|
|
672
|
+
...codecs ? { codecs } : {},
|
|
673
|
+
// Recorded even when the search could not run, so the cache recognises the output; a
|
|
674
|
+
// missing score is what marks it as worth searching once the metric is available.
|
|
675
|
+
...planned.search ? { search: planned.search } : {},
|
|
676
|
+
...score !== void 0 && found ? { score: Math.round(score * 100) / 100, scorer: found.scorer } : {},
|
|
677
|
+
encoder: encoderLabel(encoder),
|
|
678
|
+
status: "written"
|
|
679
|
+
};
|
|
680
|
+
if (size >= item.sourceBytes) {
|
|
681
|
+
await ctx.fs.discardTemp(temp);
|
|
682
|
+
return { output: { ...result, status: "already-optimal" }, wrote: true };
|
|
683
|
+
}
|
|
684
|
+
const digest = await sha256File(temp);
|
|
685
|
+
const verdict = await existingPolicy(planned.path, digest, ctx);
|
|
686
|
+
if (verdict) {
|
|
687
|
+
await ctx.fs.discardTemp(temp);
|
|
688
|
+
return { output: { ...result, sha256: digest, status: verdict }, wrote: false };
|
|
689
|
+
}
|
|
690
|
+
await ctx.fs.commitTemp(temp, planned.path);
|
|
691
|
+
return { output: { ...result, sha256: digest }, wrote: true };
|
|
692
|
+
} catch (error) {
|
|
693
|
+
await ctx.fs.discardTemp(temp);
|
|
694
|
+
throw error;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
async function makePoster(planned, sourcePath, probe, ctx, encoderLabel) {
|
|
698
|
+
const at = Math.max(0, probe.durationSec * planned.at);
|
|
699
|
+
const png = await ctx.engine.frame(sourcePath, at, planned.width);
|
|
700
|
+
const encoded = await ctx.imageEngine.encode(png, {
|
|
701
|
+
format: "avif",
|
|
702
|
+
width: planned.width,
|
|
703
|
+
lossless: false,
|
|
704
|
+
quality: planned.quality,
|
|
705
|
+
effort: planned.effort
|
|
706
|
+
});
|
|
707
|
+
const digest = sha256(encoded.data);
|
|
708
|
+
const output = {
|
|
709
|
+
path: planned.path,
|
|
710
|
+
role: "poster",
|
|
711
|
+
format: "avif",
|
|
712
|
+
width: encoded.width,
|
|
713
|
+
height: encoded.height,
|
|
714
|
+
bytes: encoded.data.byteLength,
|
|
715
|
+
sha256: digest,
|
|
716
|
+
quality: planned.quality,
|
|
717
|
+
effort: planned.effort,
|
|
718
|
+
at: planned.at,
|
|
719
|
+
encoder: encoded.encoder || encoderLabel("poster"),
|
|
720
|
+
status: "written"
|
|
721
|
+
};
|
|
722
|
+
const verdict = await existingPolicy(planned.path, digest, ctx);
|
|
723
|
+
if (verdict) return { output: { ...output, status: verdict }, wrote: false };
|
|
724
|
+
await ctx.fs.writeOutput(planned.path, encoded.data);
|
|
725
|
+
return { output, wrote: true };
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// src/core/quality/pixels.ts
|
|
729
|
+
function hasTransparency(image) {
|
|
730
|
+
if (image.channels !== 4) return false;
|
|
731
|
+
for (let i = 3; i < image.data.length; i += 4) if (image.data[i] < 255) return true;
|
|
732
|
+
return false;
|
|
733
|
+
}
|
|
734
|
+
function flatten(image, background) {
|
|
735
|
+
const { width, height, channels, data } = image;
|
|
736
|
+
const out = new Uint8Array(width * height * 3);
|
|
737
|
+
const base = background === "white" ? 255 : 0;
|
|
738
|
+
for (let p = 0, i = 0, o = 0; p < width * height; p++, i += channels, o += 3) {
|
|
739
|
+
const alpha = channels === 4 ? data[i + 3] : 255;
|
|
740
|
+
if (alpha === 255) {
|
|
741
|
+
out[o] = data[i];
|
|
742
|
+
out[o + 1] = data[i + 1];
|
|
743
|
+
out[o + 2] = data[i + 2];
|
|
744
|
+
} else {
|
|
745
|
+
const a = alpha / 255;
|
|
746
|
+
out[o] = Math.round(data[i] * a + base * (1 - a));
|
|
747
|
+
out[o + 1] = Math.round(data[i + 1] * a + base * (1 - a));
|
|
748
|
+
out[o + 2] = Math.round(data[i + 2] * a + base * (1 - a));
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
return { data: out, width, height };
|
|
752
|
+
}
|
|
753
|
+
function dropAlpha(image) {
|
|
754
|
+
if (image.channels === 3) return image;
|
|
755
|
+
const out = new Uint8Array(image.width * image.height * 3);
|
|
756
|
+
for (let p = 0, i = 0, o = 0; p < image.width * image.height; p++, i += 4, o += 3) {
|
|
757
|
+
out[o] = image.data[i];
|
|
758
|
+
out[o + 1] = image.data[i + 1];
|
|
759
|
+
out[o + 2] = image.data[i + 2];
|
|
760
|
+
}
|
|
761
|
+
return { data: out, width: image.width, height: image.height, channels: 3 };
|
|
762
|
+
}
|
|
763
|
+
function detail(image, w) {
|
|
764
|
+
const { channels, data } = image;
|
|
765
|
+
const luma = (x, y) => {
|
|
766
|
+
const i = (y * image.width + x) * channels;
|
|
767
|
+
return data[i] * 0.299 + data[i + 1] * 0.587 + data[i + 2] * 0.114;
|
|
768
|
+
};
|
|
769
|
+
let sum = 0;
|
|
770
|
+
let count = 0;
|
|
771
|
+
for (let y = w.y; y < w.y + w.height - 1; y += 2) {
|
|
772
|
+
for (let x = w.x; x < w.x + w.width - 1; x += 2) {
|
|
773
|
+
const l = luma(x, y);
|
|
774
|
+
sum += Math.abs(luma(x + 1, y) - l) + Math.abs(luma(x, y + 1) - l);
|
|
775
|
+
count++;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
return count === 0 ? 0 : sum / count;
|
|
779
|
+
}
|
|
780
|
+
function pickWindow(image, size) {
|
|
781
|
+
const w = Math.min(size, image.width);
|
|
782
|
+
const h = Math.min(size, image.height);
|
|
783
|
+
const nx = Math.max(1, Math.floor(image.width / w));
|
|
784
|
+
const ny = Math.max(1, Math.floor(image.height / h));
|
|
785
|
+
const ox = Math.floor((image.width - nx * w) / 2);
|
|
786
|
+
const oy = Math.floor((image.height - ny * h) / 2);
|
|
787
|
+
const tiles = [];
|
|
788
|
+
for (let j = 0; j < ny; j++) {
|
|
789
|
+
for (let i = 0; i < nx; i++) {
|
|
790
|
+
const window = { x: ox + i * w, y: oy + j * h, width: w, height: h };
|
|
791
|
+
tiles.push({ window, detail: detail(image, window), index: j * nx + i });
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
tiles.sort((a, b) => a.detail - b.detail || a.index - b.index);
|
|
795
|
+
return tiles[Math.floor((tiles.length - 1) / 2)].window;
|
|
796
|
+
}
|
|
797
|
+
function crop(image, w) {
|
|
798
|
+
const { channels } = image;
|
|
799
|
+
const out = new Uint8Array(w.width * w.height * channels);
|
|
800
|
+
for (let y = 0; y < w.height; y++) {
|
|
801
|
+
const from = ((w.y + y) * image.width + w.x) * channels;
|
|
802
|
+
out.set(image.data.subarray(from, from + w.width * channels), y * w.width * channels);
|
|
803
|
+
}
|
|
804
|
+
return { data: out, width: w.width, height: w.height, channels };
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// src/core/quality/search.ts
|
|
808
|
+
var MAX_BUMPS = 8;
|
|
809
|
+
var STEP = 0.9;
|
|
810
|
+
var MIN_WIDTH_FRACTION = 0.5;
|
|
811
|
+
var even = (n) => Math.max(2, Math.floor(n / 2) * 2);
|
|
812
|
+
var kb = (bytes) => `${(bytes / 1024).toFixed(1)} KB`;
|
|
813
|
+
var Cancelled = class extends Error {
|
|
814
|
+
};
|
|
815
|
+
async function searchQuality(request) {
|
|
816
|
+
const { engine, scorer, search } = request;
|
|
817
|
+
if (!engine.decodeRaw || !engine.encodeRaw) {
|
|
818
|
+
return {
|
|
819
|
+
ok: false,
|
|
820
|
+
reason: "this image engine cannot decode raw pixels",
|
|
821
|
+
encodes: 0,
|
|
822
|
+
unsearchable: true
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
const decodeRaw = engine.decodeRaw.bind(engine);
|
|
826
|
+
const encodeRaw = engine.encodeRaw.bind(engine);
|
|
827
|
+
let encodes = 0;
|
|
828
|
+
const checkAbort = () => {
|
|
829
|
+
if (request.signal?.aborted) throw new Cancelled();
|
|
830
|
+
};
|
|
831
|
+
async function atWidth(width) {
|
|
832
|
+
const ref = await decodeRaw(request.input, { width });
|
|
833
|
+
if (Math.min(ref.width, ref.height) < 8) {
|
|
834
|
+
throw new Unsearchable(`a ${ref.width}x${ref.height} image is too small to score`);
|
|
835
|
+
}
|
|
836
|
+
const opaque = !hasTransparency(ref);
|
|
837
|
+
const backgrounds = opaque ? ["white"] : ["white", "black"];
|
|
838
|
+
const scoreAgainst = async (reference, candidate) => {
|
|
839
|
+
let worst = Infinity;
|
|
840
|
+
for (const bg of backgrounds) {
|
|
841
|
+
worst = Math.min(worst, await scorer.score(flatten(reference, bg), flatten(candidate, bg)));
|
|
842
|
+
}
|
|
843
|
+
return worst;
|
|
844
|
+
};
|
|
845
|
+
const window = pickWindow(ref, request.cropSize ?? 512);
|
|
846
|
+
const refCrop = crop(ref, window);
|
|
847
|
+
const cropInput = opaque ? dropAlpha(refCrop) : refCrop;
|
|
848
|
+
const cropScores = /* @__PURE__ */ new Map();
|
|
849
|
+
const cropScore = async (quality2) => {
|
|
850
|
+
const known = cropScores.get(quality2);
|
|
851
|
+
if (known !== void 0) return known;
|
|
852
|
+
checkAbort();
|
|
853
|
+
const encoded = await encodeRaw(cropInput, {
|
|
854
|
+
format: request.format,
|
|
855
|
+
quality: quality2,
|
|
856
|
+
effort: request.effort
|
|
857
|
+
});
|
|
858
|
+
encodes++;
|
|
859
|
+
const decoded = await decodeRaw(Buffer.from(encoded.data), {});
|
|
860
|
+
const score = await scoreAgainst(refCrop, decoded);
|
|
861
|
+
cropScores.set(quality2, score);
|
|
862
|
+
return score;
|
|
863
|
+
};
|
|
864
|
+
const smallestQuality = async (minScore) => {
|
|
865
|
+
if (await cropScore(100) < minScore) return { quality: 100, reached: false };
|
|
866
|
+
let lo2 = 1;
|
|
867
|
+
let hi2 = 100;
|
|
868
|
+
while (lo2 < hi2) {
|
|
869
|
+
const mid = lo2 + hi2 >> 1;
|
|
870
|
+
if (await cropScore(mid) >= minScore) hi2 = mid;
|
|
871
|
+
else lo2 = mid + 1;
|
|
872
|
+
}
|
|
873
|
+
return { quality: lo2, reached: true };
|
|
874
|
+
};
|
|
875
|
+
const full = async (quality2) => {
|
|
876
|
+
checkAbort();
|
|
877
|
+
const encoded = await engine.encode(request.input, {
|
|
878
|
+
format: request.format,
|
|
879
|
+
width,
|
|
880
|
+
lossless: false,
|
|
881
|
+
quality: quality2,
|
|
882
|
+
effort: request.effort
|
|
883
|
+
});
|
|
884
|
+
encodes++;
|
|
885
|
+
const decoded = await decodeRaw(Buffer.from(encoded.data), {});
|
|
886
|
+
if (decoded.width !== ref.width || decoded.height !== ref.height) {
|
|
887
|
+
throw new Error(
|
|
888
|
+
`the ${request.format} output is ${decoded.width}x${decoded.height}, expected ${ref.width}x${ref.height}`
|
|
889
|
+
);
|
|
890
|
+
}
|
|
891
|
+
return { encoded, score: await scoreAgainst(ref, decoded) };
|
|
892
|
+
};
|
|
893
|
+
const notes = [];
|
|
894
|
+
const target = await smallestQuality(search.target);
|
|
895
|
+
if (!target.reached) {
|
|
896
|
+
notes.push(
|
|
897
|
+
`the sampled region does not reach ${search.target} even at quality 100; confirming at full size`
|
|
898
|
+
);
|
|
899
|
+
}
|
|
900
|
+
let quality = target.quality;
|
|
901
|
+
let current = await full(quality);
|
|
902
|
+
let bumps = 0;
|
|
903
|
+
while (current.score < search.target && quality < 100 && bumps < MAX_BUMPS) {
|
|
904
|
+
const next = Math.min(100, quality + (bumps < 4 ? 1 : 2));
|
|
905
|
+
notes.push(
|
|
906
|
+
`quality ${quality} scored ${current.score.toFixed(2)} at full size (target ${search.target}); raised to ${next}`
|
|
907
|
+
);
|
|
908
|
+
quality = next;
|
|
909
|
+
current = await full(quality);
|
|
910
|
+
bumps++;
|
|
911
|
+
}
|
|
912
|
+
if (current.score < search.target) {
|
|
913
|
+
return {
|
|
914
|
+
ok: false,
|
|
915
|
+
kind: "score",
|
|
916
|
+
reason: `quality ${quality} scores ${current.score.toFixed(1)}, below the target ${search.target}`
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
const cap = search.maxBytes;
|
|
920
|
+
if (cap === void 0 || current.encoded.data.byteLength <= cap) {
|
|
921
|
+
return { ok: true, quality, encoded: current.encoded, score: current.score, notes };
|
|
922
|
+
}
|
|
923
|
+
const floor = search.floor ?? search.target;
|
|
924
|
+
let lowQuality = Math.min((await smallestQuality(floor)).quality, quality);
|
|
925
|
+
let low = lowQuality === quality ? current : await full(lowQuality);
|
|
926
|
+
let lowBumps = 0;
|
|
927
|
+
while (low.score < floor && lowQuality < quality && lowBumps < MAX_BUMPS) {
|
|
928
|
+
lowQuality++;
|
|
929
|
+
low = lowQuality === quality ? current : await full(lowQuality);
|
|
930
|
+
lowBumps++;
|
|
931
|
+
}
|
|
932
|
+
if (low.encoded.data.byteLength > cap) {
|
|
933
|
+
return {
|
|
934
|
+
ok: false,
|
|
935
|
+
kind: "size",
|
|
936
|
+
reason: `${kb(low.encoded.data.byteLength)} at the score floor ${floor} (quality ${lowQuality}) is over the ${kb(cap)} cap`
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
let best = { quality: lowQuality, ...low };
|
|
940
|
+
let lo = lowQuality;
|
|
941
|
+
let hi = quality;
|
|
942
|
+
while (hi - lo > 1) {
|
|
943
|
+
const mid = lo + hi >> 1;
|
|
944
|
+
const candidate = await full(mid);
|
|
945
|
+
if (candidate.encoded.data.byteLength <= cap && candidate.score >= floor) {
|
|
946
|
+
lo = mid;
|
|
947
|
+
best = { quality: mid, ...candidate };
|
|
948
|
+
} else hi = mid;
|
|
949
|
+
}
|
|
950
|
+
notes.push(
|
|
951
|
+
`${kb(current.encoded.data.byteLength)} at quality ${quality} is over ${kb(cap)}; lowered to ${best.quality} (score ${best.score.toFixed(1)}, floor ${floor})`
|
|
952
|
+
);
|
|
953
|
+
return { ok: true, quality: best.quality, encoded: best.encoded, score: best.score, notes };
|
|
954
|
+
}
|
|
955
|
+
try {
|
|
956
|
+
let width = request.width;
|
|
957
|
+
let last = await atWidth(width);
|
|
958
|
+
if (last.ok) return { ...last, width, encodes };
|
|
959
|
+
const widthNotes = [];
|
|
960
|
+
while (!last.ok && last.kind === "size") {
|
|
961
|
+
const next = even(width * STEP);
|
|
962
|
+
const halfWidth = Math.ceil(request.width * MIN_WIDTH_FRACTION);
|
|
963
|
+
if (next <= request.minWidth || next < halfWidth || next >= width) {
|
|
964
|
+
const stoppedAt = next <= request.minWidth ? `the next planned width (${request.minWidth}px)` : `half the planned width (${halfWidth}px)`;
|
|
965
|
+
return {
|
|
966
|
+
ok: false,
|
|
967
|
+
encodes,
|
|
968
|
+
reason: `${last.reason}; stepping down reached ${stoppedAt} without fitting`
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
widthNotes.push(`${width}px did not fit (${last.reason})`);
|
|
972
|
+
width = next;
|
|
973
|
+
last = await atWidth(width);
|
|
974
|
+
}
|
|
975
|
+
if (last.ok) {
|
|
976
|
+
return {
|
|
977
|
+
ok: true,
|
|
978
|
+
quality: last.quality,
|
|
979
|
+
width,
|
|
980
|
+
encoded: last.encoded,
|
|
981
|
+
score: last.score,
|
|
982
|
+
encodes,
|
|
983
|
+
notes: [
|
|
984
|
+
`stepped the width down from ${request.width}px to ${width}px to fit (${widthNotes.length} wider ${widthNotes.length === 1 ? "size" : "sizes"} did not)`,
|
|
985
|
+
...last.notes
|
|
986
|
+
]
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
return { ok: false, encodes, reason: last.reason };
|
|
990
|
+
} catch (error) {
|
|
991
|
+
if (error instanceof Cancelled)
|
|
992
|
+
return { ok: false, encodes, reason: "cancelled", cancelled: true };
|
|
993
|
+
if (error instanceof Unsearchable) {
|
|
994
|
+
return { ok: false, encodes, reason: error.message, unsearchable: true };
|
|
995
|
+
}
|
|
996
|
+
throw error;
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
var Unsearchable = class extends Error {
|
|
1000
|
+
};
|
|
1001
|
+
|
|
1002
|
+
// src/core/execute/run.ts
|
|
1003
|
+
function message2(error) {
|
|
1004
|
+
return error instanceof Error ? error.message : String(error);
|
|
1005
|
+
}
|
|
1006
|
+
function header(item) {
|
|
1007
|
+
return {
|
|
1008
|
+
source: item.source,
|
|
1009
|
+
sourceBytes: item.sourceBytes,
|
|
1010
|
+
sourceSha256: item.sourceSha256,
|
|
1011
|
+
sourceWidth: item.sourceWidth,
|
|
1012
|
+
sourceHeight: item.sourceHeight,
|
|
1013
|
+
preset: item.preset
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
async function runItem(item, options, owned, warnings) {
|
|
1017
|
+
const base = header(item);
|
|
1018
|
+
if (item.skip) return { ...base, status: "skipped", reason: item.skip.reason, outputs: [] };
|
|
1019
|
+
let input;
|
|
1020
|
+
try {
|
|
1021
|
+
input = await options.fs.readSource(resolve2(options.projectRoot, ...item.source.split("/")));
|
|
1022
|
+
} catch (error) {
|
|
1023
|
+
return { ...base, status: "failed", reason: message2(error), outputs: [] };
|
|
1024
|
+
}
|
|
1025
|
+
if (sha256(input) !== item.sourceSha256) {
|
|
1026
|
+
return {
|
|
1027
|
+
...base,
|
|
1028
|
+
status: "failed",
|
|
1029
|
+
reason: "source changed since the plan was created; re-run to build a fresh plan",
|
|
1030
|
+
outputs: []
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
const hashOutput = async (path) => {
|
|
1034
|
+
const data = await options.fs.readOutput(path);
|
|
1035
|
+
return data ? sha256(data) : null;
|
|
1036
|
+
};
|
|
1037
|
+
const entry = options.manifest?.sources[item.source];
|
|
1038
|
+
if (!options.force && entry) {
|
|
1039
|
+
const state = await checkCache(item, entry, hashOutput);
|
|
1040
|
+
if (state.kind === "hit") {
|
|
1041
|
+
const recorded = recordedByPlannedPath(entry);
|
|
1042
|
+
const outputs2 = item.outputs.map((planned) => {
|
|
1043
|
+
const r = recorded.get(planned.path);
|
|
1044
|
+
return {
|
|
1045
|
+
path: r.path,
|
|
1046
|
+
format: r.format,
|
|
1047
|
+
width: r.width,
|
|
1048
|
+
height: r.height,
|
|
1049
|
+
bytes: r.bytes,
|
|
1050
|
+
lossless: r.lossless,
|
|
1051
|
+
quality: r.quality,
|
|
1052
|
+
effort: r.effort,
|
|
1053
|
+
...r.sha256 ? { sha256: r.sha256 } : {},
|
|
1054
|
+
encoder: r.encoder,
|
|
1055
|
+
...r.search ? { search: r.search } : {},
|
|
1056
|
+
...r.score !== void 0 ? { score: r.score } : {},
|
|
1057
|
+
...r.scorer ? { scorer: r.scorer } : {},
|
|
1058
|
+
status: r.status === "already-optimal" ? "already-optimal" : "unchanged"
|
|
1059
|
+
};
|
|
1060
|
+
});
|
|
1061
|
+
return { ...base, status: "cached", outputs: outputs2 };
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
const outputs = [];
|
|
1065
|
+
const record = (output) => {
|
|
1066
|
+
outputs.push(output);
|
|
1067
|
+
options.onOutputDone?.(item.source, output);
|
|
1068
|
+
};
|
|
1069
|
+
let failed = false;
|
|
1070
|
+
for (const planned of item.outputs) {
|
|
1071
|
+
const shared = {
|
|
1072
|
+
path: planned.path,
|
|
1073
|
+
format: planned.format,
|
|
1074
|
+
lossless: planned.lossless,
|
|
1075
|
+
quality: planned.quality,
|
|
1076
|
+
effort: planned.effort
|
|
1077
|
+
};
|
|
1078
|
+
if (options.signal?.aborted) {
|
|
1079
|
+
record({ ...shared, width: planned.width, height: 0, bytes: 0, status: "cancelled" });
|
|
1080
|
+
continue;
|
|
1081
|
+
}
|
|
1082
|
+
try {
|
|
1083
|
+
let encoded;
|
|
1084
|
+
let outPath = planned.path;
|
|
1085
|
+
let quality = planned.quality;
|
|
1086
|
+
let searched = {};
|
|
1087
|
+
if (planned.search && !planned.lossless && options.quality) {
|
|
1088
|
+
const biggerSibling = Math.max(
|
|
1089
|
+
0,
|
|
1090
|
+
...item.outputs.filter((o) => o.format === planned.format && o.width < planned.width).map((o) => o.width)
|
|
1091
|
+
);
|
|
1092
|
+
const scorer = await options.quality.scorer();
|
|
1093
|
+
const outcome = await searchQuality({
|
|
1094
|
+
engine: options.engine,
|
|
1095
|
+
scorer,
|
|
1096
|
+
input,
|
|
1097
|
+
format: planned.format,
|
|
1098
|
+
width: planned.width,
|
|
1099
|
+
effort: planned.effort,
|
|
1100
|
+
search: planned.search,
|
|
1101
|
+
minWidth: biggerSibling,
|
|
1102
|
+
signal: options.signal
|
|
1103
|
+
});
|
|
1104
|
+
if (outcome.ok) {
|
|
1105
|
+
encoded = outcome.encoded;
|
|
1106
|
+
quality = outcome.quality;
|
|
1107
|
+
const stepped = outcome.width !== planned.width;
|
|
1108
|
+
if (stepped) {
|
|
1109
|
+
outPath = planned.path.replace(
|
|
1110
|
+
new RegExp(`-${planned.width}\\.${planned.format}$`),
|
|
1111
|
+
`-${outcome.width}.${planned.format}`
|
|
1112
|
+
);
|
|
1113
|
+
}
|
|
1114
|
+
searched = {
|
|
1115
|
+
search: { ...planned.search, ...stepped ? { plannedPath: planned.path } : {} },
|
|
1116
|
+
score: Math.round(outcome.score * 100) / 100,
|
|
1117
|
+
scorer: scorer.kind
|
|
1118
|
+
};
|
|
1119
|
+
for (const note of outcome.notes) warnings.push(`${outPath}: ${note}`);
|
|
1120
|
+
} else if (outcome.cancelled) {
|
|
1121
|
+
record({ ...shared, width: planned.width, height: 0, bytes: 0, status: "cancelled" });
|
|
1122
|
+
continue;
|
|
1123
|
+
} else if (outcome.unsearchable) {
|
|
1124
|
+
warnings.push(
|
|
1125
|
+
`${planned.path}: perceptual search was not possible (${outcome.reason}); used quality ${planned.quality}`
|
|
1126
|
+
);
|
|
1127
|
+
} else {
|
|
1128
|
+
record({
|
|
1129
|
+
...shared,
|
|
1130
|
+
width: planned.width,
|
|
1131
|
+
height: 0,
|
|
1132
|
+
bytes: 0,
|
|
1133
|
+
status: "unmet",
|
|
1134
|
+
error: outcome.reason
|
|
1135
|
+
});
|
|
1136
|
+
failed = true;
|
|
1137
|
+
continue;
|
|
1138
|
+
}
|
|
1139
|
+
} else if (planned.search && !planned.lossless) {
|
|
1140
|
+
warnings.push(
|
|
1141
|
+
`${planned.path}: no quality scorer available; used the fixed quality ${planned.quality} instead of the ssimulacra2 target`
|
|
1142
|
+
);
|
|
1143
|
+
}
|
|
1144
|
+
encoded ??= await options.engine.encode(input, planned);
|
|
1145
|
+
const result = {
|
|
1146
|
+
...shared,
|
|
1147
|
+
path: outPath,
|
|
1148
|
+
quality,
|
|
1149
|
+
width: encoded.width,
|
|
1150
|
+
height: encoded.height,
|
|
1151
|
+
bytes: encoded.data.byteLength,
|
|
1152
|
+
encoder: encoded.encoder,
|
|
1153
|
+
...searched
|
|
1154
|
+
};
|
|
1155
|
+
if (encoded.data.byteLength >= item.sourceBytes) {
|
|
1156
|
+
record({ ...result, status: "already-optimal" });
|
|
1157
|
+
continue;
|
|
1158
|
+
}
|
|
1159
|
+
const newHash = sha256(encoded.data);
|
|
1160
|
+
const onDisk = await options.fs.readOutput(outPath);
|
|
1161
|
+
if (onDisk) {
|
|
1162
|
+
const diskHash = sha256(onDisk);
|
|
1163
|
+
if (diskHash === newHash) {
|
|
1164
|
+
record({ ...result, sha256: newHash, status: "unchanged" });
|
|
1165
|
+
continue;
|
|
1166
|
+
}
|
|
1167
|
+
if (!options.force && owned.get(outPath) !== diskHash) {
|
|
1168
|
+
warnings.push(
|
|
1169
|
+
`${outPath}: a file is already there that bytecut did not write or that was edited; left alone (use --force to overwrite)`
|
|
1170
|
+
);
|
|
1171
|
+
record({ ...result, status: "protected" });
|
|
1172
|
+
continue;
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
await options.fs.writeOutput(outPath, encoded.data);
|
|
1176
|
+
record({ ...result, sha256: newHash, status: "written" });
|
|
1177
|
+
} catch (error) {
|
|
1178
|
+
failed = true;
|
|
1179
|
+
record({
|
|
1180
|
+
...shared,
|
|
1181
|
+
width: planned.width,
|
|
1182
|
+
height: 0,
|
|
1183
|
+
bytes: 0,
|
|
1184
|
+
status: "failed",
|
|
1185
|
+
error: message2(error)
|
|
1186
|
+
});
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
const cancelled = outputs.some((o) => o.status === "cancelled");
|
|
1190
|
+
return { ...base, status: failed ? "failed" : cancelled ? "cancelled" : "done", outputs };
|
|
1191
|
+
}
|
|
1192
|
+
async function runPlan(plan, options) {
|
|
1193
|
+
const results = new Array(plan.items.length);
|
|
1194
|
+
const warnings = [];
|
|
1195
|
+
const owned = /* @__PURE__ */ new Map();
|
|
1196
|
+
for (const source of Object.values(options.manifest?.sources ?? {})) {
|
|
1197
|
+
for (const out of source.outputs) if (out.sha256) owned.set(out.path, out.sha256);
|
|
1198
|
+
}
|
|
1199
|
+
for (const source of Object.values(options.manifest?.videos ?? {})) {
|
|
1200
|
+
for (const out of source.outputs) if (out.sha256) owned.set(out.path, out.sha256);
|
|
1201
|
+
}
|
|
1202
|
+
let next = 0;
|
|
1203
|
+
let done = 0;
|
|
1204
|
+
const workers = Math.max(1, Math.min(options.concurrency ?? 2, Math.max(1, plan.items.length)));
|
|
1205
|
+
async function worker() {
|
|
1206
|
+
for (; ; ) {
|
|
1207
|
+
const index = next++;
|
|
1208
|
+
const item = plan.items[index];
|
|
1209
|
+
if (!item) return;
|
|
1210
|
+
let result;
|
|
1211
|
+
if (options.signal?.aborted) {
|
|
1212
|
+
result = { ...header(item), status: "cancelled", outputs: [] };
|
|
1213
|
+
} else {
|
|
1214
|
+
options.onItemStart?.(item.source);
|
|
1215
|
+
result = await runItem(item, options, owned, warnings);
|
|
1216
|
+
}
|
|
1217
|
+
results[index] = result;
|
|
1218
|
+
options.onItemDone?.(result, ++done, plan.items.length);
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
const videoItems = plan.videos ?? [];
|
|
1222
|
+
const videoResults = new Array(videoItems.length);
|
|
1223
|
+
let videoNext = 0;
|
|
1224
|
+
let videoDone = 0;
|
|
1225
|
+
const videoWorkers = Math.max(
|
|
1226
|
+
1,
|
|
1227
|
+
Math.min(options.video?.concurrency ?? 1, Math.max(1, videoItems.length))
|
|
1228
|
+
);
|
|
1229
|
+
const videoWarnings = /* @__PURE__ */ new Set();
|
|
1230
|
+
async function videoWorker() {
|
|
1231
|
+
for (; ; ) {
|
|
1232
|
+
const index = videoNext++;
|
|
1233
|
+
const item = videoItems[index];
|
|
1234
|
+
if (!item) return;
|
|
1235
|
+
let result;
|
|
1236
|
+
const lane = options.video;
|
|
1237
|
+
const fs = options.fs;
|
|
1238
|
+
if (options.signal?.aborted) {
|
|
1239
|
+
result = videoHeader(item, "cancelled");
|
|
1240
|
+
} else if (!lane || !fs.reserveTemp || !fs.commitTemp || !fs.discardTemp || !fs.outputPath) {
|
|
1241
|
+
result = videoHeader(item, "skipped", `needs ffmpeg: ${lane?.needsFfmpeg ?? "not found"}`);
|
|
1242
|
+
} else {
|
|
1243
|
+
options.onVideoItemStart?.(item.source);
|
|
1244
|
+
result = await runVideoItem(item, {
|
|
1245
|
+
projectRoot: options.projectRoot,
|
|
1246
|
+
engine: lane.engine,
|
|
1247
|
+
imageEngine: options.engine,
|
|
1248
|
+
fs,
|
|
1249
|
+
manifest: options.manifest,
|
|
1250
|
+
force: options.force ?? false,
|
|
1251
|
+
signal: options.signal,
|
|
1252
|
+
owned,
|
|
1253
|
+
warnings: videoWarnings,
|
|
1254
|
+
quality: options.quality,
|
|
1255
|
+
onProgress: options.onVideoProgress,
|
|
1256
|
+
onOutputDone: options.onVideoOutputDone
|
|
1257
|
+
});
|
|
1258
|
+
}
|
|
1259
|
+
videoResults[index] = result;
|
|
1260
|
+
options.onVideoItemDone?.(result, ++videoDone, videoItems.length);
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
await Promise.all([
|
|
1264
|
+
...Array.from({ length: workers }, worker),
|
|
1265
|
+
...videoItems.length > 0 ? Array.from({ length: videoWorkers }, videoWorker) : []
|
|
1266
|
+
]);
|
|
1267
|
+
for (const w of videoWarnings) warnings.push(w);
|
|
1268
|
+
return {
|
|
1269
|
+
items: results,
|
|
1270
|
+
...videoItems.length > 0 ? { videos: videoResults } : {},
|
|
1271
|
+
cancelled: options.signal?.aborted ?? false,
|
|
1272
|
+
warnings: warnings.sort()
|
|
1273
|
+
};
|
|
1274
|
+
}
|
|
1275
|
+
function videoHeader(item, status, reason) {
|
|
1276
|
+
return {
|
|
1277
|
+
source: item.source,
|
|
1278
|
+
sourceBytes: item.sourceBytes,
|
|
1279
|
+
sourceSha256: item.sourceSha256,
|
|
1280
|
+
sourceWidth: item.sourceWidth,
|
|
1281
|
+
sourceHeight: item.sourceHeight,
|
|
1282
|
+
durationSec: item.durationSec,
|
|
1283
|
+
preset: item.preset,
|
|
1284
|
+
status,
|
|
1285
|
+
...reason ? { reason } : {},
|
|
1286
|
+
outputs: []
|
|
1287
|
+
};
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
// src/core/manifest/update.ts
|
|
1291
|
+
function outputUrl(publicPath, path) {
|
|
1292
|
+
return `${publicPath.replace(/\/+$/, "")}/${path}`;
|
|
1293
|
+
}
|
|
1294
|
+
function toManifestOutput(out, publicPath) {
|
|
1295
|
+
return {
|
|
1296
|
+
path: out.path,
|
|
1297
|
+
url: outputUrl(publicPath, out.path),
|
|
1298
|
+
format: out.format,
|
|
1299
|
+
width: out.width,
|
|
1300
|
+
height: out.height,
|
|
1301
|
+
bytes: out.bytes,
|
|
1302
|
+
...out.status === "already-optimal" ? {} : { sha256: out.sha256 },
|
|
1303
|
+
lossless: out.lossless,
|
|
1304
|
+
quality: out.quality,
|
|
1305
|
+
effort: out.effort,
|
|
1306
|
+
encoder: out.encoder ?? "unknown",
|
|
1307
|
+
status: out.status === "already-optimal" ? "already-optimal" : "written",
|
|
1308
|
+
...out.search ? { search: out.search } : {},
|
|
1309
|
+
...out.score !== void 0 ? { score: out.score } : {},
|
|
1310
|
+
...out.scorer ? { scorer: out.scorer } : {}
|
|
1311
|
+
};
|
|
1312
|
+
}
|
|
1313
|
+
function mergeItem(old, item, plannedPaths, publicPath, catalog) {
|
|
1314
|
+
const produced = /* @__PURE__ */ new Map();
|
|
1315
|
+
for (const out of item.outputs) {
|
|
1316
|
+
if (out.status === "written" || out.status === "unchanged" || out.status === "already-optimal") {
|
|
1317
|
+
produced.set(out.path, toManifestOutput(out, publicPath));
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
if (produced.size === 0) return null;
|
|
1321
|
+
const sameSource = old?.sha256 === item.sourceSha256;
|
|
1322
|
+
const merged = new Map(produced);
|
|
1323
|
+
for (const previous of old?.outputs ?? []) {
|
|
1324
|
+
if (merged.has(previous.path)) continue;
|
|
1325
|
+
const standsFor = previous.search?.plannedPath;
|
|
1326
|
+
if (standsFor !== void 0 && merged.has(standsFor)) {
|
|
1327
|
+
const { plannedPath: _superseded, ...search } = previous.search;
|
|
1328
|
+
void _superseded;
|
|
1329
|
+
merged.set(previous.path, { ...previous, search });
|
|
1330
|
+
} else if (plannedPaths.has(standsFor ?? previous.path) && !sameSource) {
|
|
1331
|
+
merged.set(previous.path, { ...previous, status: "stale" });
|
|
1332
|
+
} else {
|
|
1333
|
+
merged.set(previous.path, previous);
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
const priority = (catalog?.image ?? IMAGE_PRESETS)[item.preset]?.priority === true;
|
|
1337
|
+
return {
|
|
1338
|
+
sha256: item.sourceSha256,
|
|
1339
|
+
bytes: item.sourceBytes,
|
|
1340
|
+
width: item.sourceWidth,
|
|
1341
|
+
height: item.sourceHeight,
|
|
1342
|
+
preset: item.preset,
|
|
1343
|
+
...priority ? { priority } : {},
|
|
1344
|
+
// A placeholder describes the content it was made from, so it only survives an unchanged source.
|
|
1345
|
+
...sameSource && old?.placeholder ? { placeholder: old.placeholder } : {},
|
|
1346
|
+
outputs: [...merged.values()].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)
|
|
1347
|
+
};
|
|
1348
|
+
}
|
|
1349
|
+
function toManifestVideoOutput(out, publicPath) {
|
|
1350
|
+
return {
|
|
1351
|
+
path: out.path,
|
|
1352
|
+
url: outputUrl(publicPath, out.path),
|
|
1353
|
+
role: out.role,
|
|
1354
|
+
format: out.format,
|
|
1355
|
+
...out.codec ? { codec: out.codec } : {},
|
|
1356
|
+
width: out.width,
|
|
1357
|
+
height: out.height,
|
|
1358
|
+
...out.fps !== void 0 ? { fps: out.fps } : {},
|
|
1359
|
+
bytes: out.bytes,
|
|
1360
|
+
...out.status === "already-optimal" || !out.sha256 ? {} : { sha256: out.sha256 },
|
|
1361
|
+
...out.crf !== void 0 ? { crf: out.crf } : {},
|
|
1362
|
+
...out.speed !== void 0 ? { speed: out.speed } : {},
|
|
1363
|
+
...out.maxWidth !== void 0 ? { maxWidth: out.maxWidth } : {},
|
|
1364
|
+
...out.maxFps !== void 0 ? { maxFps: out.maxFps } : {},
|
|
1365
|
+
...out.audio !== void 0 ? { audio: out.audio } : {},
|
|
1366
|
+
...out.quality !== void 0 ? { quality: out.quality } : {},
|
|
1367
|
+
...out.effort !== void 0 ? { effort: out.effort } : {},
|
|
1368
|
+
...out.at !== void 0 ? { at: out.at } : {},
|
|
1369
|
+
...out.codecs ? { codecs: out.codecs } : {},
|
|
1370
|
+
encoder: out.encoder ?? "unknown",
|
|
1371
|
+
status: out.status === "already-optimal" ? "already-optimal" : "written",
|
|
1372
|
+
...out.search ? { search: out.search } : {},
|
|
1373
|
+
...out.score !== void 0 ? { score: out.score } : {},
|
|
1374
|
+
...out.scorer ? { scorer: out.scorer } : {}
|
|
1375
|
+
};
|
|
1376
|
+
}
|
|
1377
|
+
function mergeVideoItem(old, item, plannedPaths, publicPath) {
|
|
1378
|
+
const produced = /* @__PURE__ */ new Map();
|
|
1379
|
+
for (const out of item.outputs) {
|
|
1380
|
+
if (out.status === "written" || out.status === "unchanged" || out.status === "already-optimal") {
|
|
1381
|
+
produced.set(out.path, toManifestVideoOutput(out, publicPath));
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
if (produced.size === 0) return null;
|
|
1385
|
+
const sameSource = old?.sha256 === item.sourceSha256;
|
|
1386
|
+
const merged = new Map(produced);
|
|
1387
|
+
for (const previous of old?.outputs ?? []) {
|
|
1388
|
+
if (merged.has(previous.path)) continue;
|
|
1389
|
+
if (plannedPaths.has(previous.path) && !sameSource) {
|
|
1390
|
+
merged.set(previous.path, { ...previous, status: "stale" });
|
|
1391
|
+
} else {
|
|
1392
|
+
merged.set(previous.path, previous);
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
return {
|
|
1396
|
+
sha256: item.sourceSha256,
|
|
1397
|
+
bytes: item.sourceBytes,
|
|
1398
|
+
width: item.sourceWidth,
|
|
1399
|
+
height: item.sourceHeight,
|
|
1400
|
+
durationSec: item.durationSec,
|
|
1401
|
+
preset: item.preset,
|
|
1402
|
+
...sameSource && old?.placeholder ? { placeholder: old.placeholder } : {},
|
|
1403
|
+
outputs: [...merged.values()].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)
|
|
1404
|
+
};
|
|
1405
|
+
}
|
|
1406
|
+
function updateManifest(previous, plan, result, tools, catalog) {
|
|
1407
|
+
const sources = { ...previous?.sources };
|
|
1408
|
+
const planned = new Map(plan.items.map((i) => [i.source, i]));
|
|
1409
|
+
for (const item of result.items) {
|
|
1410
|
+
if (item.status === "skipped" || item.status === "cached") continue;
|
|
1411
|
+
const plannedPaths = new Set(planned.get(item.source)?.outputs.map((o) => o.path));
|
|
1412
|
+
const merged = mergeItem(sources[item.source], item, plannedPaths, plan.publicPath, catalog);
|
|
1413
|
+
if (merged) sources[item.source] = merged;
|
|
1414
|
+
}
|
|
1415
|
+
const videos = { ...previous?.videos };
|
|
1416
|
+
const plannedVideos = new Map((plan.videos ?? []).map((v) => [v.source, v]));
|
|
1417
|
+
for (const item of result.videos ?? []) {
|
|
1418
|
+
if (item.status === "skipped" || item.status === "cached") continue;
|
|
1419
|
+
const plannedPaths = new Set(plannedVideos.get(item.source)?.outputs.map((o) => o.path));
|
|
1420
|
+
const merged = mergeVideoItem(videos[item.source], item, plannedPaths, plan.publicPath);
|
|
1421
|
+
if (merged) videos[item.source] = merged;
|
|
1422
|
+
}
|
|
1423
|
+
for (const entry of Object.values(sources)) {
|
|
1424
|
+
entry.outputs = entry.outputs.map((o) => ({ ...o, url: outputUrl(plan.publicPath, o.path) }));
|
|
1425
|
+
}
|
|
1426
|
+
for (const entry of Object.values(videos)) {
|
|
1427
|
+
entry.outputs = entry.outputs.map((o) => ({ ...o, url: outputUrl(plan.publicPath, o.path) }));
|
|
1428
|
+
}
|
|
1429
|
+
return { version: MANIFEST_VERSION, tools, publicPath: plan.publicPath, sources, videos };
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
// src/core/commands/plan-media.ts
|
|
1433
|
+
import { resolve as resolve3 } from "path";
|
|
1434
|
+
async function withVideos(plan, options) {
|
|
1435
|
+
const gifSources = new Set(
|
|
1436
|
+
plan.items.filter((i) => i.skip?.reason.startsWith(ANIMATED_GIF_REASON)).map((i) => i.source)
|
|
1437
|
+
);
|
|
1438
|
+
const files = [
|
|
1439
|
+
...options.videoFiles,
|
|
1440
|
+
...[...gifSources].map((s) => resolve3(options.projectRoot, ...s.split("/")))
|
|
1441
|
+
];
|
|
1442
|
+
const images = { ...plan, items: plan.items.filter((i) => !gifSources.has(i.source)) };
|
|
1443
|
+
if (files.length === 0) return images;
|
|
1444
|
+
const claimed = new Set(images.items.flatMap((i) => i.outputs.map((o) => o.path)));
|
|
1445
|
+
const engine = options.videoEngine;
|
|
1446
|
+
const videos = await buildVideoItems({
|
|
1447
|
+
files,
|
|
1448
|
+
projectRoot: options.projectRoot,
|
|
1449
|
+
staticRoot: options.staticRoot,
|
|
1450
|
+
engine,
|
|
1451
|
+
needsFfmpeg: options.needsFfmpeg,
|
|
1452
|
+
...options.presetOverrides ? { presetOverrides: options.presetOverrides } : {},
|
|
1453
|
+
...options.policy ? { policy: options.policy } : {},
|
|
1454
|
+
claimed,
|
|
1455
|
+
async probe(path, digest) {
|
|
1456
|
+
const cached = options.cache.getVideoProbe(digest);
|
|
1457
|
+
if (cached) return cached;
|
|
1458
|
+
const fresh = engine ? await engine.probe(path) : null;
|
|
1459
|
+
if (fresh) options.cache.setVideoProbe(digest, fresh);
|
|
1460
|
+
return fresh;
|
|
1461
|
+
}
|
|
1462
|
+
});
|
|
1463
|
+
return videos.length > 0 ? { ...images, videos } : images;
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
// src/core/scan/collect.ts
|
|
1467
|
+
import { readFile, readdir, stat as stat2 } from "fs/promises";
|
|
1468
|
+
import { extname, join, relative, resolve as resolve4, sep } from "path";
|
|
1469
|
+
import ignore from "ignore";
|
|
1470
|
+
var RASTER = /* @__PURE__ */ new Set([".jpg", ".jpeg", ".png", ".webp", ".avif", ".gif"]);
|
|
1471
|
+
var VIDEO = /* @__PURE__ */ new Set([".mp4", ".webm", ".mov", ".m4v", ".mkv"]);
|
|
1472
|
+
var SVG = /* @__PURE__ */ new Set([".svg"]);
|
|
1473
|
+
var ALWAYS_SKIPPED = /* @__PURE__ */ new Set(["node_modules", ".git"]);
|
|
1474
|
+
function isIgnored(scopes, absolute, isDir) {
|
|
1475
|
+
for (let i = scopes.length - 1; i >= 0; i--) {
|
|
1476
|
+
const scope = scopes[i];
|
|
1477
|
+
const rel = relative(scope.base, absolute).split(sep).join("/");
|
|
1478
|
+
if (rel === "" || rel.startsWith("..")) continue;
|
|
1479
|
+
const verdict = scope.matcher.test(isDir ? `${rel}/` : rel);
|
|
1480
|
+
if (verdict.ignored) return true;
|
|
1481
|
+
if (verdict.unignored) return false;
|
|
1482
|
+
}
|
|
1483
|
+
return false;
|
|
1484
|
+
}
|
|
1485
|
+
async function loadScope(dir) {
|
|
1486
|
+
try {
|
|
1487
|
+
const text = await readFile(join(dir, ".gitignore"), "utf8");
|
|
1488
|
+
return { base: dir, matcher: ignore().add(text) };
|
|
1489
|
+
} catch {
|
|
1490
|
+
return null;
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
async function walkFiles(startDir, options, visit) {
|
|
1494
|
+
const excluded = options.exclude?.length ? ignore().add(options.exclude) : null;
|
|
1495
|
+
const isExcluded = (path, isDir) => {
|
|
1496
|
+
if (!excluded) return false;
|
|
1497
|
+
const rel2 = relative(options.cwd, path).split(sep).join("/");
|
|
1498
|
+
return rel2 !== "" && !rel2.startsWith("..") && excluded.ignores(isDir ? `${rel2}/` : rel2);
|
|
1499
|
+
};
|
|
1500
|
+
async function walk(dir, inherited) {
|
|
1501
|
+
const own = await loadScope(dir);
|
|
1502
|
+
const scopes = own ? [...inherited, own] : inherited;
|
|
1503
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
1504
|
+
const path = join(dir, entry.name);
|
|
1505
|
+
if (entry.isDirectory()) {
|
|
1506
|
+
if (ALWAYS_SKIPPED.has(entry.name) || path === options.outDir) continue;
|
|
1507
|
+
if (isIgnored(scopes, path, true) || isExcluded(path, true)) continue;
|
|
1508
|
+
await walk(path, scopes);
|
|
1509
|
+
} else if (entry.isFile()) {
|
|
1510
|
+
if (isIgnored(scopes, path, false) || isExcluded(path, false)) continue;
|
|
1511
|
+
await visit(path);
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
const chain = [];
|
|
1516
|
+
const rel = relative(options.cwd, startDir);
|
|
1517
|
+
if (rel !== "" && !rel.startsWith("..")) {
|
|
1518
|
+
const rootScope = await loadScope(options.cwd);
|
|
1519
|
+
if (rootScope) chain.push(rootScope);
|
|
1520
|
+
let current = options.cwd;
|
|
1521
|
+
for (const part of rel.split(sep).slice(0, -1)) {
|
|
1522
|
+
current = join(current, part);
|
|
1523
|
+
const scope = await loadScope(current);
|
|
1524
|
+
if (scope) chain.push(scope);
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
await walk(startDir, chain);
|
|
1528
|
+
}
|
|
1529
|
+
async function collectAssets(inputs, options) {
|
|
1530
|
+
const files = /* @__PURE__ */ new Set();
|
|
1531
|
+
const svgs = /* @__PURE__ */ new Set();
|
|
1532
|
+
const videos = /* @__PURE__ */ new Set();
|
|
1533
|
+
const sourceDirs = [];
|
|
1534
|
+
const unsupported = [];
|
|
1535
|
+
function classify2(path, explicit) {
|
|
1536
|
+
const ext = extname(path).toLowerCase();
|
|
1537
|
+
if (RASTER.has(ext)) files.add(path);
|
|
1538
|
+
else if (SVG.has(ext)) svgs.add(path);
|
|
1539
|
+
else if (VIDEO.has(ext)) videos.add(path);
|
|
1540
|
+
else if (explicit) unsupported.push(path);
|
|
1541
|
+
}
|
|
1542
|
+
for (const input of inputs) {
|
|
1543
|
+
const absolute = resolve4(options.cwd, input);
|
|
1544
|
+
const info = await stat2(absolute).catch(() => null);
|
|
1545
|
+
if (!info) throw new BytecutError("INPUT_NOT_FOUND", `No such file or folder: ${input}`);
|
|
1546
|
+
if (info.isDirectory()) {
|
|
1547
|
+
sourceDirs.push(absolute);
|
|
1548
|
+
if (absolute !== options.outDir)
|
|
1549
|
+
await walkFiles(absolute, options, (p) => classify2(p, false));
|
|
1550
|
+
} else {
|
|
1551
|
+
classify2(absolute, true);
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
const sorted = (s) => [...s].sort();
|
|
1555
|
+
return {
|
|
1556
|
+
files: sorted(files),
|
|
1557
|
+
svgs: sorted(svgs),
|
|
1558
|
+
videos: sorted(videos),
|
|
1559
|
+
sourceDirs,
|
|
1560
|
+
unsupported
|
|
1561
|
+
};
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
// src/core/commands/optimize.ts
|
|
1565
|
+
function engineFingerprint(engine, extra = {}) {
|
|
1566
|
+
const v = { ...engine.versions(), ...extra };
|
|
1567
|
+
return Object.keys(v).sort().map((k) => `${k}=${v[k]}`).join(";");
|
|
1568
|
+
}
|
|
1569
|
+
async function optimize(options) {
|
|
1570
|
+
const { projectRoot, engine } = options;
|
|
1571
|
+
const outDir = resolve5(projectRoot, options.outDir);
|
|
1572
|
+
let plan = options.savedPlan;
|
|
1573
|
+
let sourceFiles;
|
|
1574
|
+
let sourceDirs = [];
|
|
1575
|
+
let unsupported = [];
|
|
1576
|
+
let svgs = [];
|
|
1577
|
+
let videos = [];
|
|
1578
|
+
if (plan) {
|
|
1579
|
+
sourceFiles = [...plan.items, ...plan.videos ?? []].map(
|
|
1580
|
+
(i) => resolve5(projectRoot, ...i.source.split("/"))
|
|
1581
|
+
);
|
|
1582
|
+
} else {
|
|
1583
|
+
const found = await collectAssets(options.inputs ?? [], {
|
|
1584
|
+
cwd: projectRoot,
|
|
1585
|
+
outDir,
|
|
1586
|
+
...options.exclude ? { exclude: options.exclude } : {}
|
|
1587
|
+
});
|
|
1588
|
+
sourceFiles = [...found.files, ...found.videos];
|
|
1589
|
+
sourceDirs = found.sourceDirs;
|
|
1590
|
+
unsupported = found.unsupported;
|
|
1591
|
+
svgs = found.svgs;
|
|
1592
|
+
videos = found.videos;
|
|
1593
|
+
if (found.files.length === 0 && found.videos.length === 0) {
|
|
1594
|
+
return {
|
|
1595
|
+
plan: {
|
|
1596
|
+
version: 1,
|
|
1597
|
+
outDir: "",
|
|
1598
|
+
publicPath: options.publicPath ?? DEFAULT_PUBLIC_PATH,
|
|
1599
|
+
items: []
|
|
1600
|
+
},
|
|
1601
|
+
cache: /* @__PURE__ */ new Map(),
|
|
1602
|
+
findings: [],
|
|
1603
|
+
unsupported,
|
|
1604
|
+
svgs,
|
|
1605
|
+
videos,
|
|
1606
|
+
notes: []
|
|
1607
|
+
};
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
const fs = await SafeFs.create({ projectRoot, outDir, sourceFiles, sourceDirs });
|
|
1611
|
+
const videoVersions = options.videoEngine ? await options.videoEngine.versions() : {};
|
|
1612
|
+
const analysisCache = await AnalysisCache.load(fs, engineFingerprint(engine, videoVersions));
|
|
1613
|
+
const analyze = async (data, digest) => {
|
|
1614
|
+
const cached = analysisCache.getAnalysis(digest);
|
|
1615
|
+
if (cached) return cached;
|
|
1616
|
+
const fresh = await engine.analyze(data);
|
|
1617
|
+
analysisCache.setAnalysis(digest, fresh);
|
|
1618
|
+
return fresh;
|
|
1619
|
+
};
|
|
1620
|
+
const policy = options.policy ?? BUILTIN_POLICY;
|
|
1621
|
+
const override = options.presetOverride ? getImagePreset(options.presetOverride, policy.catalog) : null;
|
|
1622
|
+
const choosePreset = (source, analysis) => {
|
|
1623
|
+
const perFile = options.presetOverrides?.[source];
|
|
1624
|
+
if (perFile) {
|
|
1625
|
+
return {
|
|
1626
|
+
preset: getImagePreset(perFile, policy.catalog),
|
|
1627
|
+
reason: `set to ${perFile} for this file`
|
|
1628
|
+
};
|
|
1629
|
+
}
|
|
1630
|
+
if (override) {
|
|
1631
|
+
return { preset: override, reason: `forced with --preset ${override.name}` };
|
|
1632
|
+
}
|
|
1633
|
+
const matched = policy.assetFor(source);
|
|
1634
|
+
const assetPreset = matched ? policy.catalog.image[matched.preset] : void 0;
|
|
1635
|
+
if (matched && assetPreset) {
|
|
1636
|
+
return {
|
|
1637
|
+
preset: assetPreset,
|
|
1638
|
+
reason: `set to ${matched.preset} by assets "${matched.pattern}"`,
|
|
1639
|
+
confidence: "high"
|
|
1640
|
+
};
|
|
1641
|
+
}
|
|
1642
|
+
const verdict = classify(source, analysis);
|
|
1643
|
+
return {
|
|
1644
|
+
preset: getImagePreset(verdict.preset, policy.catalog),
|
|
1645
|
+
reason: verdict.reason,
|
|
1646
|
+
confidence: verdict.confidence
|
|
1647
|
+
};
|
|
1648
|
+
};
|
|
1649
|
+
if (!plan) {
|
|
1650
|
+
const rasterFiles = sourceFiles.filter((f) => !videos.includes(f));
|
|
1651
|
+
plan = await buildPlan({
|
|
1652
|
+
files: rasterFiles,
|
|
1653
|
+
projectRoot,
|
|
1654
|
+
outDir,
|
|
1655
|
+
...options.staticRoot !== void 0 ? { staticRoot: options.staticRoot } : {},
|
|
1656
|
+
...options.publicPath ? { publicPath: options.publicPath } : {},
|
|
1657
|
+
choosePreset,
|
|
1658
|
+
engine,
|
|
1659
|
+
analyze,
|
|
1660
|
+
readSource: (path) => fs.readSource(path)
|
|
1661
|
+
});
|
|
1662
|
+
plan = await withVideos(plan, {
|
|
1663
|
+
projectRoot,
|
|
1664
|
+
staticRoot: options.staticRoot ?? ".",
|
|
1665
|
+
videoFiles: videos,
|
|
1666
|
+
videoEngine: options.videoEngine ?? null,
|
|
1667
|
+
needsFfmpeg: options.needsFfmpeg ?? "install ffmpeg",
|
|
1668
|
+
presetOverrides: options.presetOverrides,
|
|
1669
|
+
policy,
|
|
1670
|
+
cache: analysisCache
|
|
1671
|
+
});
|
|
1672
|
+
}
|
|
1673
|
+
const finalPlan = plan;
|
|
1674
|
+
const manifest = await readManifest(fs);
|
|
1675
|
+
const outputHash = async (path) => {
|
|
1676
|
+
const data = await fs.readOutput(path);
|
|
1677
|
+
return data ? sha256(data) : null;
|
|
1678
|
+
};
|
|
1679
|
+
const cache = /* @__PURE__ */ new Map();
|
|
1680
|
+
for (const item of finalPlan.items) {
|
|
1681
|
+
if (item.skip) continue;
|
|
1682
|
+
cache.set(item.source, await checkCache(item, manifest?.sources[item.source], outputHash));
|
|
1683
|
+
}
|
|
1684
|
+
const caps = options.videoEngine ? await options.videoEngine.capabilities() : null;
|
|
1685
|
+
const missingCodecs = /* @__PURE__ */ new Set();
|
|
1686
|
+
if (caps) {
|
|
1687
|
+
if (!caps.libsvtav1 && !caps.libaomAv1) missingCodecs.add("av1");
|
|
1688
|
+
if (!caps.libx264) missingCodecs.add("h264");
|
|
1689
|
+
}
|
|
1690
|
+
const notes = videoNotes(finalPlan, caps, options.needsFfmpeg);
|
|
1691
|
+
for (const item of finalPlan.videos ?? []) {
|
|
1692
|
+
if (item.skip) continue;
|
|
1693
|
+
cache.set(
|
|
1694
|
+
item.source,
|
|
1695
|
+
await checkVideoCache(item, manifest?.videos[item.source], outputHash, missingCodecs)
|
|
1696
|
+
);
|
|
1697
|
+
}
|
|
1698
|
+
const ffmpegVersion = videoVersions.ffmpeg;
|
|
1699
|
+
const videoEncoders = {};
|
|
1700
|
+
if (caps && ffmpegVersion) {
|
|
1701
|
+
const av1 = caps.libsvtav1 ? "libsvtav1" : caps.libaomAv1 ? "libaom-av1" : null;
|
|
1702
|
+
if (av1) videoEncoders.av1 = `${av1} (ffmpeg ${ffmpegVersion})`;
|
|
1703
|
+
if (caps.libx264) videoEncoders.h264 = `libx264 (ffmpeg ${ffmpegVersion})`;
|
|
1704
|
+
}
|
|
1705
|
+
const audit = (current) => runProjectRules({
|
|
1706
|
+
plan: finalPlan,
|
|
1707
|
+
manifest: current,
|
|
1708
|
+
videoEncoders,
|
|
1709
|
+
unavailableCodecs: missingCodecs,
|
|
1710
|
+
encoders: engine.encoders(),
|
|
1711
|
+
outputHash,
|
|
1712
|
+
sourceExists: async (source) => (await stat3(resolve5(projectRoot, ...source.split("/"))).catch(() => null))?.isFile() ?? false
|
|
1713
|
+
});
|
|
1714
|
+
if (!options.execute) {
|
|
1715
|
+
return {
|
|
1716
|
+
plan: finalPlan,
|
|
1717
|
+
cache,
|
|
1718
|
+
findings: await audit(manifest),
|
|
1719
|
+
unsupported,
|
|
1720
|
+
svgs,
|
|
1721
|
+
videos,
|
|
1722
|
+
notes
|
|
1723
|
+
};
|
|
1724
|
+
}
|
|
1725
|
+
let selected = null;
|
|
1726
|
+
const scorerNotes = [];
|
|
1727
|
+
const makeScorer = options.makeScorer;
|
|
1728
|
+
const quality = makeScorer ? {
|
|
1729
|
+
async scorer() {
|
|
1730
|
+
selected ??= makeScorer(fs).then((s) => {
|
|
1731
|
+
scorerNotes.push(...s.notes);
|
|
1732
|
+
return s;
|
|
1733
|
+
});
|
|
1734
|
+
return (await selected).scorer;
|
|
1735
|
+
}
|
|
1736
|
+
} : void 0;
|
|
1737
|
+
const runAll = () => runPlan(finalPlan, {
|
|
1738
|
+
projectRoot,
|
|
1739
|
+
engine,
|
|
1740
|
+
fs,
|
|
1741
|
+
manifest,
|
|
1742
|
+
...quality ? { quality } : {},
|
|
1743
|
+
...options.force !== void 0 ? { force: options.force } : {},
|
|
1744
|
+
...options.concurrency !== void 0 ? { concurrency: options.concurrency } : {},
|
|
1745
|
+
...options.signal ? { signal: options.signal } : {},
|
|
1746
|
+
...options.onItemStart ? { onItemStart: options.onItemStart } : {},
|
|
1747
|
+
...options.onOutputDone ? { onOutputDone: options.onOutputDone } : {},
|
|
1748
|
+
...options.onItemDone ? { onItemDone: options.onItemDone } : {},
|
|
1749
|
+
...options.videoEngine ? {
|
|
1750
|
+
video: {
|
|
1751
|
+
engine: options.videoEngine,
|
|
1752
|
+
...options.videoConcurrency !== void 0 ? { concurrency: options.videoConcurrency } : {},
|
|
1753
|
+
...options.needsFfmpeg ? { needsFfmpeg: options.needsFfmpeg } : {}
|
|
1754
|
+
}
|
|
1755
|
+
} : {},
|
|
1756
|
+
...options.onVideoItemStart ? { onVideoItemStart: options.onVideoItemStart } : {},
|
|
1757
|
+
...options.onVideoProgress ? { onVideoProgress: options.onVideoProgress } : {},
|
|
1758
|
+
...options.onVideoOutputDone ? { onVideoOutputDone: options.onVideoOutputDone } : {},
|
|
1759
|
+
...options.onVideoItemDone ? { onVideoItemDone: options.onVideoItemDone } : {}
|
|
1760
|
+
});
|
|
1761
|
+
let result;
|
|
1762
|
+
try {
|
|
1763
|
+
result = await runAll();
|
|
1764
|
+
} finally {
|
|
1765
|
+
const started = selected ? await selected.catch(() => null) : null;
|
|
1766
|
+
await started?.scorer.close();
|
|
1767
|
+
}
|
|
1768
|
+
result.warnings.push(...scorerNotes);
|
|
1769
|
+
const updated = updateManifest(
|
|
1770
|
+
manifest,
|
|
1771
|
+
finalPlan,
|
|
1772
|
+
result,
|
|
1773
|
+
{ bytecut: options.toolVersion, ...engine.versions() },
|
|
1774
|
+
policy.catalog
|
|
1775
|
+
);
|
|
1776
|
+
const placeholdersChanged = await refreshPlaceholders(updated, finalPlan, {
|
|
1777
|
+
kind: options.placeholder ?? "dominant-color",
|
|
1778
|
+
engine,
|
|
1779
|
+
readSource: (path) => fs.readSource(path),
|
|
1780
|
+
readOutput: (path) => fs.readOutput(path),
|
|
1781
|
+
projectRoot
|
|
1782
|
+
});
|
|
1783
|
+
const touched = placeholdersChanged || result.items.some((i) => i.status !== "skipped") || (result.videos ?? []).some((v) => v.status !== "skipped");
|
|
1784
|
+
if (touched) {
|
|
1785
|
+
const next = serializeManifest(updated);
|
|
1786
|
+
const previous = manifest ? serializeManifest({ ...manifest, tools: updated.tools }) : null;
|
|
1787
|
+
if (next !== previous) await fs.writeOutput(MANIFEST_FILE, Buffer.from(next));
|
|
1788
|
+
}
|
|
1789
|
+
await analysisCache.save(fs);
|
|
1790
|
+
return {
|
|
1791
|
+
plan: finalPlan,
|
|
1792
|
+
cache,
|
|
1793
|
+
findings: await audit(updated),
|
|
1794
|
+
unsupported,
|
|
1795
|
+
svgs,
|
|
1796
|
+
videos,
|
|
1797
|
+
notes,
|
|
1798
|
+
result
|
|
1799
|
+
};
|
|
1800
|
+
}
|
|
1801
|
+
async function readManifest(fs) {
|
|
1802
|
+
const data = await fs.readOutput(MANIFEST_FILE);
|
|
1803
|
+
return data ? parseManifest(data.toString("utf8")) : null;
|
|
1804
|
+
}
|
|
1805
|
+
function videoNotes(plan, caps, needsFfmpeg) {
|
|
1806
|
+
const videos = plan.videos ?? [];
|
|
1807
|
+
if (videos.length === 0) return [];
|
|
1808
|
+
const notes = [];
|
|
1809
|
+
if (!caps) {
|
|
1810
|
+
notes.push(
|
|
1811
|
+
`ffmpeg not found: ${videos.length} video file${videos.length === 1 ? "" : "s"} cannot be processed (${needsFfmpeg ?? "install ffmpeg"}). Images are unaffected.`
|
|
1812
|
+
);
|
|
1813
|
+
return notes;
|
|
1814
|
+
}
|
|
1815
|
+
if (!caps.libsvtav1 && !caps.libaomAv1) {
|
|
1816
|
+
notes.push(
|
|
1817
|
+
"No AV1 encoder (libsvtav1 / libaom-av1): AV1 outputs will be skipped; H.264 and posters are still produced."
|
|
1818
|
+
);
|
|
1819
|
+
} else if (!caps.libsvtav1) {
|
|
1820
|
+
notes.push("libsvtav1 not found: AV1 will be encoded with libaom-av1, which is much slower.");
|
|
1821
|
+
}
|
|
1822
|
+
if (!caps.libx264) {
|
|
1823
|
+
notes.push("libx264 not found: H.264 fallback outputs will be skipped.");
|
|
1824
|
+
}
|
|
1825
|
+
if (!caps.libvmaf) {
|
|
1826
|
+
notes.push("libvmaf not available: using the preset's fixed quality (no VMAF search).");
|
|
1827
|
+
}
|
|
1828
|
+
return notes;
|
|
1829
|
+
}
|
|
1830
|
+
async function refreshPlaceholders(manifest, plan, deps) {
|
|
1831
|
+
const { kind, engine } = deps;
|
|
1832
|
+
if (kind !== "none" && !engine.placeholder) return false;
|
|
1833
|
+
let changed = false;
|
|
1834
|
+
for (const item of plan.items) {
|
|
1835
|
+
const entry = manifest.sources[item.source];
|
|
1836
|
+
if (!entry || item.skip || entry.sha256 !== item.sourceSha256) continue;
|
|
1837
|
+
if (kind === "none") {
|
|
1838
|
+
if (entry.placeholder) {
|
|
1839
|
+
const { placeholder: _gone, ...rest } = entry;
|
|
1840
|
+
void _gone;
|
|
1841
|
+
manifest.sources[item.source] = rest;
|
|
1842
|
+
changed = true;
|
|
1843
|
+
}
|
|
1844
|
+
continue;
|
|
1845
|
+
}
|
|
1846
|
+
if (entry.placeholder?.kind === kind) continue;
|
|
1847
|
+
const data = await deps.readSource(resolve5(deps.projectRoot, ...item.source.split("/")));
|
|
1848
|
+
manifest.sources[item.source] = {
|
|
1849
|
+
...entry,
|
|
1850
|
+
placeholder: await engine.placeholder(data, kind)
|
|
1851
|
+
};
|
|
1852
|
+
changed = true;
|
|
1853
|
+
}
|
|
1854
|
+
for (const item of plan.videos ?? []) {
|
|
1855
|
+
const entry = manifest.videos[item.source];
|
|
1856
|
+
if (!entry || item.skip || entry.sha256 !== item.sourceSha256) continue;
|
|
1857
|
+
if (kind === "none") {
|
|
1858
|
+
if (entry.placeholder) {
|
|
1859
|
+
const { placeholder: _gone, ...rest } = entry;
|
|
1860
|
+
void _gone;
|
|
1861
|
+
manifest.videos[item.source] = rest;
|
|
1862
|
+
changed = true;
|
|
1863
|
+
}
|
|
1864
|
+
continue;
|
|
1865
|
+
}
|
|
1866
|
+
if (entry.placeholder?.kind === kind) continue;
|
|
1867
|
+
const poster = entry.outputs.find((o) => o.role === "poster" && o.status === "written");
|
|
1868
|
+
const data = poster ? await deps.readOutput(poster.path) : null;
|
|
1869
|
+
if (!data) continue;
|
|
1870
|
+
manifest.videos[item.source] = { ...entry, placeholder: await engine.placeholder(data, kind) };
|
|
1871
|
+
changed = true;
|
|
1872
|
+
}
|
|
1873
|
+
return changed;
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
// src/cli/format.ts
|
|
1877
|
+
function formatPlan(plan, cache) {
|
|
1878
|
+
const lines = [];
|
|
1879
|
+
const active = plan.items.filter((i) => !i.skip);
|
|
1880
|
+
const videos = plan.videos ?? [];
|
|
1881
|
+
const activeVideos = videos.filter((v) => !v.skip);
|
|
1882
|
+
const unchanged = [...active, ...activeVideos].filter(
|
|
1883
|
+
(i) => cache?.get(i.source)?.kind === "hit"
|
|
1884
|
+
).length;
|
|
1885
|
+
const note = unchanged > 0 ? ` (${unchanged} unchanged, will be skipped)` : "";
|
|
1886
|
+
const what = activeVideos.length > 0 ? `${plural(active.length, "image")}, ${plural(activeVideos.length, "video")}` : plural(active.length, "image");
|
|
1887
|
+
lines.push(`Plan: ${what} \u2192 ${plan.outDir}${note}`);
|
|
1888
|
+
for (const item of plan.items) {
|
|
1889
|
+
lines.push("");
|
|
1890
|
+
const dims = `${formatBytes(item.sourceBytes)}, ${item.sourceWidth}x${item.sourceHeight}`;
|
|
1891
|
+
const preset = item.skip ? "" : `, ${item.preset}${item.classification ? `: ${item.classification.reason}` : ""}`;
|
|
1892
|
+
lines.push(`${item.source} (${dims}${preset})`);
|
|
1893
|
+
if (item.skip) {
|
|
1894
|
+
lines.push(` skipped: ${item.skip.reason}`);
|
|
1895
|
+
continue;
|
|
1896
|
+
}
|
|
1897
|
+
if (cache?.get(item.source)?.kind === "hit") {
|
|
1898
|
+
lines.push(" unchanged: outputs are up to date");
|
|
1899
|
+
continue;
|
|
1900
|
+
}
|
|
1901
|
+
for (const out of item.outputs) {
|
|
1902
|
+
const mode = out.lossless ? "lossless" : out.search ? `ssimulacra2 >= ${out.search.target}${out.search.maxBytes !== void 0 ? `, <= ${formatBytes(out.search.maxBytes)}` : ""}` : `q${out.quality}`;
|
|
1903
|
+
lines.push(` ${out.path} (${out.format}, ${out.width}w, ${mode})`);
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
for (const video of videos) {
|
|
1907
|
+
lines.push("");
|
|
1908
|
+
if (video.skip) {
|
|
1909
|
+
lines.push(`${video.source} (${formatBytes(video.sourceBytes)})`);
|
|
1910
|
+
lines.push(` skipped: ${video.skip.reason}`);
|
|
1911
|
+
continue;
|
|
1912
|
+
}
|
|
1913
|
+
const facts = [
|
|
1914
|
+
formatBytes(video.sourceBytes),
|
|
1915
|
+
`${video.sourceWidth}x${video.sourceHeight}`,
|
|
1916
|
+
`${video.durationSec.toFixed(1)}s`,
|
|
1917
|
+
`${Math.round(video.fps * 100) / 100}fps`,
|
|
1918
|
+
video.hasAudio ? "audio" : "silent"
|
|
1919
|
+
].join(", ");
|
|
1920
|
+
const why = video.classification ? `: ${video.classification.reason}` : "";
|
|
1921
|
+
lines.push(`${video.source} (${facts}, ${video.preset}${why})`);
|
|
1922
|
+
if (cache?.get(video.source)?.kind === "hit") {
|
|
1923
|
+
lines.push(" unchanged: outputs are up to date");
|
|
1924
|
+
continue;
|
|
1925
|
+
}
|
|
1926
|
+
for (const out of video.outputs) {
|
|
1927
|
+
if (out.role === "video") {
|
|
1928
|
+
const audio = out.audio === "strip" ? "no audio" : `audio ${out.audioBitrate}`;
|
|
1929
|
+
const mode = out.search ? `${out.search.metric} >= ${out.search.target} (starts at crf ${out.crf})` : `crf ${out.crf}`;
|
|
1930
|
+
lines.push(` ${out.path} (${out.codec}, ${out.width}w, ${mode}, ${audio})`);
|
|
1931
|
+
} else {
|
|
1932
|
+
lines.push(` ${out.path} (poster, ${out.width}w)`);
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
return lines.join("\n");
|
|
1937
|
+
}
|
|
1938
|
+
function itemLabel(item) {
|
|
1939
|
+
switch (item.status) {
|
|
1940
|
+
case "skipped":
|
|
1941
|
+
return `skipped: ${item.reason}`;
|
|
1942
|
+
case "cached":
|
|
1943
|
+
return "unchanged";
|
|
1944
|
+
case "failed":
|
|
1945
|
+
return `failed${item.reason ? `: ${item.reason}` : ""}`;
|
|
1946
|
+
case "cancelled":
|
|
1947
|
+
return "cancelled";
|
|
1948
|
+
default:
|
|
1949
|
+
return "";
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
function outputLine(out, sourceBytes) {
|
|
1953
|
+
const head = ` ${out.format.padEnd(4)} ${String(out.width).padStart(5)}w`;
|
|
1954
|
+
const size = formatBytes(out.bytes);
|
|
1955
|
+
switch (out.status) {
|
|
1956
|
+
case "written":
|
|
1957
|
+
return `${head} ${size.padStart(9)} ${percentChange(sourceBytes, out.bytes)}${searchNote(out)}`;
|
|
1958
|
+
case "unchanged":
|
|
1959
|
+
return `${head} ${size.padStart(9)} ${percentChange(sourceBytes, out.bytes)} (already on disk)${searchNote(out)}`;
|
|
1960
|
+
case "already-optimal":
|
|
1961
|
+
return `${head} already optimal (${size} \u2265 source), not written`;
|
|
1962
|
+
case "protected":
|
|
1963
|
+
return `${head} left alone: existing file not owned by bytecut (use --force)`;
|
|
1964
|
+
case "unavailable":
|
|
1965
|
+
return `${head} skipped: encoder not available`;
|
|
1966
|
+
case "failed":
|
|
1967
|
+
return `${head} failed: ${out.error}`;
|
|
1968
|
+
case "unmet":
|
|
1969
|
+
return `${head} not written: ${out.error}`;
|
|
1970
|
+
case "cancelled":
|
|
1971
|
+
return `${head} cancelled`;
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
function searchNote(out) {
|
|
1975
|
+
if (out.score === void 0) return "";
|
|
1976
|
+
const stepped = out.search?.plannedPath ? " \xB7 width stepped down" : "";
|
|
1977
|
+
return ` \xB7 q${out.quality} \xB7 ssimulacra2 ${out.score.toFixed(1)}${stepped}`;
|
|
1978
|
+
}
|
|
1979
|
+
function videoSearchNote(out) {
|
|
1980
|
+
if (!out.search || out.score === void 0 || out.crf === void 0) return "";
|
|
1981
|
+
return ` \xB7 crf ${out.crf} \xB7 ${out.search.metric} ${out.score.toFixed(1)}`;
|
|
1982
|
+
}
|
|
1983
|
+
function videoOutputLine(out, sourceBytes) {
|
|
1984
|
+
const label = out.role === "poster" ? "poster" : out.codec ?? "mp4";
|
|
1985
|
+
const head = ` ${label.padEnd(6)} ${String(out.width).padStart(5)}w`;
|
|
1986
|
+
const size = formatBytes(out.bytes);
|
|
1987
|
+
switch (out.status) {
|
|
1988
|
+
case "written":
|
|
1989
|
+
return out.role === "poster" ? `${head} ${size.padStart(9)}` : `${head} ${size.padStart(9)} ${percentChange(sourceBytes, out.bytes)}${videoSearchNote(out)}`;
|
|
1990
|
+
case "unchanged":
|
|
1991
|
+
return `${head} ${size.padStart(9)} (already on disk)${videoSearchNote(out)}`;
|
|
1992
|
+
case "already-optimal":
|
|
1993
|
+
return `${head} already optimal (${size} \u2265 source), not written`;
|
|
1994
|
+
case "protected":
|
|
1995
|
+
return `${head} left alone: existing file not owned by bytecut (use --force)`;
|
|
1996
|
+
case "unavailable":
|
|
1997
|
+
return `${head} skipped: ${out.codec === "av1" ? "no AV1 encoder installed" : "encoder not available"}`;
|
|
1998
|
+
case "failed":
|
|
1999
|
+
return `${head} failed: ${out.error}`;
|
|
2000
|
+
case "unmet":
|
|
2001
|
+
return `${head} not written: ${out.error}`;
|
|
2002
|
+
case "cancelled":
|
|
2003
|
+
return `${head} cancelled`;
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
function videoItemLabel(item) {
|
|
2007
|
+
switch (item.status) {
|
|
2008
|
+
case "skipped":
|
|
2009
|
+
return `skipped: ${item.reason}`;
|
|
2010
|
+
case "cached":
|
|
2011
|
+
return "unchanged";
|
|
2012
|
+
case "failed":
|
|
2013
|
+
return `failed${item.reason ? `: ${item.reason}` : ""}`;
|
|
2014
|
+
case "cancelled":
|
|
2015
|
+
return "cancelled";
|
|
2016
|
+
default:
|
|
2017
|
+
return `${item.preset}`;
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
function servedVideoBytes(item) {
|
|
2021
|
+
const usable = item.outputs.filter(
|
|
2022
|
+
(o) => o.role === "video" && (o.status === "written" || o.status === "unchanged")
|
|
2023
|
+
);
|
|
2024
|
+
if (usable.length === 0) return null;
|
|
2025
|
+
return Math.min(item.sourceBytes, ...usable.map((o) => o.bytes));
|
|
2026
|
+
}
|
|
2027
|
+
function servedBytes(item) {
|
|
2028
|
+
const usable = item.outputs.filter((o) => o.status === "written" || o.status === "unchanged");
|
|
2029
|
+
if (usable.length === 0) return null;
|
|
2030
|
+
const widest = Math.max(...usable.map((o) => o.width));
|
|
2031
|
+
const best = Math.min(...usable.filter((o) => o.width === widest).map((o) => o.bytes));
|
|
2032
|
+
return Math.min(best, item.sourceBytes);
|
|
2033
|
+
}
|
|
2034
|
+
function formatSummary(result) {
|
|
2035
|
+
const lines = [];
|
|
2036
|
+
let written = 0;
|
|
2037
|
+
let writtenBytes = 0;
|
|
2038
|
+
let unchangedItems = 0;
|
|
2039
|
+
let servedSource = 0;
|
|
2040
|
+
let served = 0;
|
|
2041
|
+
for (const item of result.items) {
|
|
2042
|
+
if (item.status === "cached") unchangedItems++;
|
|
2043
|
+
lines.push(`${item.source} ${formatBytes(item.sourceBytes)} ${itemLabel(item)}`.trimEnd());
|
|
2044
|
+
if (item.status !== "cached") {
|
|
2045
|
+
for (const out of item.outputs) {
|
|
2046
|
+
lines.push(outputLine(out, item.sourceBytes));
|
|
2047
|
+
if (out.status === "written") {
|
|
2048
|
+
written++;
|
|
2049
|
+
writtenBytes += out.bytes;
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
const bytes = servedBytes(item);
|
|
2054
|
+
if (bytes !== null) {
|
|
2055
|
+
servedSource += item.sourceBytes;
|
|
2056
|
+
served += bytes;
|
|
2057
|
+
}
|
|
2058
|
+
}
|
|
2059
|
+
for (const item of result.videos ?? []) {
|
|
2060
|
+
if (item.status === "cached") unchangedItems++;
|
|
2061
|
+
lines.push(
|
|
2062
|
+
`${item.source} ${formatBytes(item.sourceBytes)} ${videoItemLabel(item)}`.trimEnd()
|
|
2063
|
+
);
|
|
2064
|
+
if (item.status !== "cached") {
|
|
2065
|
+
for (const out of item.outputs) {
|
|
2066
|
+
lines.push(videoOutputLine(out, item.sourceBytes));
|
|
2067
|
+
if (out.status === "written") {
|
|
2068
|
+
written++;
|
|
2069
|
+
writtenBytes += out.bytes;
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
const bytes = servedVideoBytes(item);
|
|
2074
|
+
if (bytes !== null) {
|
|
2075
|
+
servedSource += item.sourceBytes;
|
|
2076
|
+
served += bytes;
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
lines.push("");
|
|
2080
|
+
const parts = [`${plural(written, "file")} written`];
|
|
2081
|
+
if (written > 0) parts[0] += ` (${formatBytes(writtenBytes)} across all widths)`;
|
|
2082
|
+
if (unchangedItems > 0) parts.push(`${plural(unchangedItems, "image")} unchanged`);
|
|
2083
|
+
lines.push(`Total: ${parts.join(", ")}`);
|
|
2084
|
+
if (servedSource > 0) {
|
|
2085
|
+
lines.push(
|
|
2086
|
+
`Largest size per image: ${formatBytes(servedSource)} \u2192 ${formatBytes(served)} (${percentChange(servedSource, served)})`
|
|
2087
|
+
);
|
|
2088
|
+
}
|
|
2089
|
+
if (result.cancelled) lines.push("Cancelled: no partial files were left behind.");
|
|
2090
|
+
return lines.join("\n");
|
|
2091
|
+
}
|
|
2092
|
+
function formatFindings(findings) {
|
|
2093
|
+
if (findings.length === 0) return "";
|
|
2094
|
+
const lines = ["Project:"];
|
|
2095
|
+
for (const f of findings) {
|
|
2096
|
+
const where = f.path ? `${f.source ?? ""} \u2192 ${f.path}` : f.source ?? "";
|
|
2097
|
+
lines.push(` ${f.severity.padEnd(5)} ${f.ruleId} ${where}: ${f.message}`);
|
|
2098
|
+
}
|
|
2099
|
+
return lines.join("\n");
|
|
2100
|
+
}
|
|
2101
|
+
var SEVERITY_ORDER = { error: 0, warn: 1, info: 2 };
|
|
2102
|
+
function formatAuditReport(report, options = {}) {
|
|
2103
|
+
const limit = options.all ? Infinity : options.perRule ?? 10;
|
|
2104
|
+
const groups = /* @__PURE__ */ new Map();
|
|
2105
|
+
for (const f of report.findings) groups.set(f.ruleId, [...groups.get(f.ruleId) ?? [], f]);
|
|
2106
|
+
const ordered = [...groups.entries()].sort(([ida, a], [idb, b]) => {
|
|
2107
|
+
const sa = SEVERITY_ORDER[a[0].severity] ?? 9;
|
|
2108
|
+
const sb = SEVERITY_ORDER[b[0].severity] ?? 9;
|
|
2109
|
+
return sa - sb || (ida < idb ? -1 : 1);
|
|
2110
|
+
});
|
|
2111
|
+
const lines = [];
|
|
2112
|
+
for (const [ruleId, findings] of ordered) {
|
|
2113
|
+
const saved = findings.reduce((sum, f) => sum + (f.savingsBytes ?? 0), 0);
|
|
2114
|
+
const head = `${ruleId} ${findings[0].severity} ${plural(findings.length, "file")}`;
|
|
2115
|
+
lines.push(saved > 0 ? `${head} \xB7 est. save ${formatBytes(saved)}` : head);
|
|
2116
|
+
for (const f of findings.slice(0, limit)) {
|
|
2117
|
+
const where = f.path ? `${f.source} \u2192 ${f.path}` : f.source ?? "(project)";
|
|
2118
|
+
const size = f.current !== void 0 && f.estimated !== void 0 ? `${formatBytes(f.current)} \u2192 ~${formatBytes(f.estimated)} (${percentChange(f.current, f.estimated)})` : f.current !== void 0 ? formatBytes(f.current) : "";
|
|
2119
|
+
lines.push(` ${where}${size ? ` ${size}` : ""}`);
|
|
2120
|
+
lines.push(` ${f.message}`);
|
|
2121
|
+
}
|
|
2122
|
+
if (findings.length > limit) {
|
|
2123
|
+
lines.push(` \u2026 and ${findings.length - limit} more (use --all)`);
|
|
2124
|
+
}
|
|
2125
|
+
lines.push("");
|
|
2126
|
+
}
|
|
2127
|
+
const { counts, totals } = report;
|
|
2128
|
+
const assets = [
|
|
2129
|
+
plural(counts.images + counts.skipped, "image"),
|
|
2130
|
+
plural(counts.svgs, "SVG"),
|
|
2131
|
+
plural(counts.videos, "video")
|
|
2132
|
+
].join(", ");
|
|
2133
|
+
if (report.unprobed.length > 0) {
|
|
2134
|
+
lines.push("Needs ffmpeg (not analysed):");
|
|
2135
|
+
for (const u of report.unprobed.slice(0, 10)) {
|
|
2136
|
+
lines.push(` ${u.source} ${formatBytes(u.bytes)}`);
|
|
2137
|
+
}
|
|
2138
|
+
if (report.unprobed.length > 10) lines.push(` \u2026 and ${report.unprobed.length - 10} more`);
|
|
2139
|
+
lines.push("");
|
|
2140
|
+
}
|
|
2141
|
+
lines.push(`Scanned ${assets} (${formatBytes(totals.sourceBytes)} of media).`);
|
|
2142
|
+
const by = (s) => report.findings.filter((f) => f.severity === s).length;
|
|
2143
|
+
lines.push(
|
|
2144
|
+
`${plural(report.findings.length, "finding")}: ${plural(by("error"), "error")}, ${plural(by("warn"), "warning")}, ${by("info")} info.`
|
|
2145
|
+
);
|
|
2146
|
+
if (totals.optimizedImages > 0) {
|
|
2147
|
+
lines.push(`${plural(totals.optimizedImages, "image")} already optimized.`);
|
|
2148
|
+
}
|
|
2149
|
+
if (totals.potentialSavingsBytes > 0) {
|
|
2150
|
+
lines.push(
|
|
2151
|
+
`Estimated savings from optimizing: ~${formatBytes(totals.potentialSavingsBytes)} of ${formatBytes(totals.sourceBytes)} (${percentChange(totals.sourceBytes, totals.sourceBytes - totals.potentialSavingsBytes)}). Run \`bytecut optimize -y\` to apply.`
|
|
2152
|
+
);
|
|
2153
|
+
}
|
|
2154
|
+
for (const note of report.notes) lines.push(`Note: ${note}`);
|
|
2155
|
+
return lines.join("\n");
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
export {
|
|
2159
|
+
sortFindings,
|
|
2160
|
+
AnalysisCache,
|
|
2161
|
+
walkFiles,
|
|
2162
|
+
collectAssets,
|
|
2163
|
+
withVideos,
|
|
2164
|
+
engineFingerprint,
|
|
2165
|
+
optimize,
|
|
2166
|
+
formatPlan,
|
|
2167
|
+
formatSummary,
|
|
2168
|
+
formatFindings,
|
|
2169
|
+
formatAuditReport
|
|
2170
|
+
};
|