bytecut 0.2.1 → 0.3.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/README.md +18 -4
- package/dist/{audit-EBKL6L65.js → audit-RKJNHNDZ.js} +11 -11
- package/dist/{check-XTTU5SCZ.js → check-MN7R7XOM.js} +10 -10
- package/dist/{chunk-IZ4JEL3Q.js → chunk-4AM2CCML.js} +1 -1
- package/dist/{chunk-4FIB6VNC.js → chunk-7RHHOWQT.js} +1 -1
- package/dist/{chunk-KN5QOB7R.js → chunk-ABHEWNYQ.js} +4 -4
- package/dist/{chunk-TCQFQ354.js → chunk-CSDZGNUN.js} +14 -3
- package/dist/{chunk-WI4ZO7GP.js → chunk-DZLFVN4G.js} +14 -11
- package/dist/{chunk-PQRBLHCC.js → chunk-EM7JMQND.js} +1 -1
- package/dist/{chunk-HLBNRB3S.js → chunk-GNW3OK5U.js} +198 -44
- package/dist/{chunk-RZ2V4PCS.js → chunk-JMMXC5UA.js} +3 -3
- package/dist/{chunk-FY53B4NB.js → chunk-KJK7XGWA.js} +24 -13
- package/dist/{chunk-QOG2F4SF.js → chunk-L5HLKZDW.js} +2 -2
- package/dist/{chunk-MV6J66AP.js → chunk-MUE3L5XT.js} +1 -1
- package/dist/{chunk-SKANYRVT.js → chunk-NOTYXNMY.js} +1 -1
- package/dist/{chunk-3PF6BBFK.js → chunk-PE5NKZCH.js} +18 -8
- package/dist/{chunk-S3FQYUTI.js → chunk-SMR2764T.js} +1 -1
- package/dist/{chunk-7DHE25UE.js → chunk-UZ3Z2T5I.js} +1 -1
- package/dist/cli.js +22 -22
- package/dist/{compare-MPPGTUC5.js → compare-XRMDMCZL.js} +8 -8
- package/dist/{doctor-PWCUEBD5.js → doctor-YYTLIOVG.js} +5 -5
- package/dist/{explain-LKOB7FRT.js → explain-GZWGHYRY.js} +12 -7
- package/dist/{init-CWDWVJ55.js → init-3IQAWWBW.js} +4 -4
- package/dist/{optimize-TLJG7UYF.js → optimize-BDPL4Y2Z.js} +11 -11
- package/dist/{snippet-IIPVF6PS.js → snippet-6P7CMROL.js} +7 -7
- package/dist/{tui-6WSMCHZS.js → tui-ETSPQFXB.js} +29 -17
- package/package.json +1 -1
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
MANIFEST_VERSION,
|
|
8
8
|
parseManifest,
|
|
9
9
|
serializeManifest
|
|
10
|
-
} from "./chunk-
|
|
10
|
+
} from "./chunk-CSDZGNUN.js";
|
|
11
11
|
import {
|
|
12
12
|
ANIMATED_GIF_REASON,
|
|
13
13
|
DEFAULT_PUBLIC_PATH,
|
|
@@ -16,20 +16,20 @@ import {
|
|
|
16
16
|
classify,
|
|
17
17
|
sha256,
|
|
18
18
|
sha256File
|
|
19
|
-
} from "./chunk-
|
|
19
|
+
} from "./chunk-JMMXC5UA.js";
|
|
20
20
|
import {
|
|
21
21
|
AbortedError,
|
|
22
22
|
outputWidth
|
|
23
|
-
} from "./chunk-
|
|
23
|
+
} from "./chunk-L5HLKZDW.js";
|
|
24
24
|
import {
|
|
25
25
|
BUILTIN_POLICY,
|
|
26
26
|
IMAGE_PRESETS,
|
|
27
27
|
getImagePreset,
|
|
28
28
|
pickAv1Encoder
|
|
29
|
-
} from "./chunk-
|
|
29
|
+
} from "./chunk-PE5NKZCH.js";
|
|
30
30
|
import {
|
|
31
31
|
SafeFs
|
|
32
|
-
} from "./chunk-
|
|
32
|
+
} from "./chunk-4AM2CCML.js";
|
|
33
33
|
import {
|
|
34
34
|
BytecutError,
|
|
35
35
|
PROJECT_RULES,
|
|
@@ -39,7 +39,7 @@ import {
|
|
|
39
39
|
percentChange,
|
|
40
40
|
plural,
|
|
41
41
|
recordedByPlannedPath
|
|
42
|
-
} from "./chunk-
|
|
42
|
+
} from "./chunk-KJK7XGWA.js";
|
|
43
43
|
|
|
44
44
|
// src/core/commands/optimize.ts
|
|
45
45
|
import { stat as stat3 } from "fs/promises";
|
|
@@ -149,6 +149,7 @@ import { stat } from "fs/promises";
|
|
|
149
149
|
import { resolve } from "path";
|
|
150
150
|
|
|
151
151
|
// src/core/quality/video-search.ts
|
|
152
|
+
var evenDown = (n) => Math.max(2, Math.floor(n / 2) * 2);
|
|
152
153
|
var CLIP_SECONDS = 2;
|
|
153
154
|
var WHOLE_MAX_SECONDS = 6;
|
|
154
155
|
var CLIP_POINTS = [0.2, 0.5, 0.8];
|
|
@@ -160,6 +161,13 @@ var CRF_RANGE = {
|
|
|
160
161
|
};
|
|
161
162
|
var MAX_CONFIRM_BUMPS = 3;
|
|
162
163
|
var CONFIRM_STEP = 2;
|
|
164
|
+
var WIDTH_STEP = 0.8;
|
|
165
|
+
var MIN_WIDTH_FRACTION = 0.5;
|
|
166
|
+
function stepWidth(plannedWidth, width) {
|
|
167
|
+
const next = evenDown(width * WIDTH_STEP);
|
|
168
|
+
const half = Math.ceil(plannedWidth * MIN_WIDTH_FRACTION);
|
|
169
|
+
return next < half || next >= width ? null : next;
|
|
170
|
+
}
|
|
163
171
|
function clipsFor(durationSec) {
|
|
164
172
|
if (durationSec <= WHOLE_MAX_SECONDS) {
|
|
165
173
|
return { clips: [{ startSec: 0, durationSec }], whole: true };
|
|
@@ -172,7 +180,6 @@ function clipsFor(durationSec) {
|
|
|
172
180
|
}));
|
|
173
181
|
return { clips, whole: false };
|
|
174
182
|
}
|
|
175
|
-
var evenDown = (n) => Math.max(2, Math.floor(n / 2) * 2);
|
|
176
183
|
function scoreGeometry(probe, maxWidth) {
|
|
177
184
|
const out = outputWidth(probe.width, maxWidth);
|
|
178
185
|
const width = evenDown(Math.min(SCORE_WIDTH, out));
|
|
@@ -214,6 +221,28 @@ async function findCrf(options) {
|
|
|
214
221
|
}
|
|
215
222
|
return { ok: true, crf: good, score: scores.get(good), probes: scores.size };
|
|
216
223
|
}
|
|
224
|
+
async function fitCrfToSize(options) {
|
|
225
|
+
const sizes = /* @__PURE__ */ new Map();
|
|
226
|
+
const size = async (crf) => {
|
|
227
|
+
const known = sizes.get(crf);
|
|
228
|
+
if (known !== void 0) return known;
|
|
229
|
+
const bytes = await options.sizeAt(crf);
|
|
230
|
+
sizes.set(crf, bytes);
|
|
231
|
+
return bytes;
|
|
232
|
+
};
|
|
233
|
+
const first = await size(options.lo);
|
|
234
|
+
if (first <= options.maxBytes) return { ok: true, crf: options.lo, bytes: first };
|
|
235
|
+
const last = await size(options.hi);
|
|
236
|
+
if (last > options.maxBytes) return { ok: false, crf: options.hi, bytes: last };
|
|
237
|
+
let bad = options.lo;
|
|
238
|
+
let good = options.hi;
|
|
239
|
+
while (good - bad > 1) {
|
|
240
|
+
const mid = Math.floor((bad + good) / 2);
|
|
241
|
+
if (await size(mid) <= options.maxBytes) good = mid;
|
|
242
|
+
else bad = mid;
|
|
243
|
+
}
|
|
244
|
+
return { ok: true, crf: good, bytes: sizes.get(good) };
|
|
245
|
+
}
|
|
217
246
|
var mean = (values) => values.reduce((a, b) => a + b, 0) / values.length;
|
|
218
247
|
function createVideoSearcher(request) {
|
|
219
248
|
const { clips, whole } = clipsFor(request.probe.durationSec);
|
|
@@ -276,8 +305,20 @@ function createVideoSearcher(request) {
|
|
|
276
305
|
request.onProbe?.(crf, score);
|
|
277
306
|
return score;
|
|
278
307
|
}
|
|
308
|
+
async function sizeAt(crf) {
|
|
309
|
+
const bytes = await Promise.all(
|
|
310
|
+
clips.map((clip) => request.engine.sampleBytes(sample(clip, crf), request.signal))
|
|
311
|
+
);
|
|
312
|
+
const total = bytes.reduce((a, b) => a + b, 0);
|
|
313
|
+
const seconds = clips.reduce((a, c) => a + c.durationSec, 0);
|
|
314
|
+
return Math.round(
|
|
315
|
+
whole ? total : total / Math.max(1e-3, seconds) * request.probe.durationSec
|
|
316
|
+
);
|
|
317
|
+
}
|
|
279
318
|
return {
|
|
280
319
|
whole,
|
|
320
|
+
score: measure,
|
|
321
|
+
sizeAt,
|
|
281
322
|
async find() {
|
|
282
323
|
const range = CRF_RANGE[request.codec];
|
|
283
324
|
return findCrf({
|
|
@@ -331,10 +372,12 @@ function probeFromItem(item) {
|
|
|
331
372
|
}
|
|
332
373
|
var evenHeight = (probe, width) => Math.max(2, Math.round(probe.height * width / Math.max(1, probe.width) / 2) * 2);
|
|
333
374
|
function sameParams(planned, r) {
|
|
334
|
-
if (planned.role !== r.role
|
|
375
|
+
if (planned.role !== r.role) return false;
|
|
376
|
+
const stepped = r.search?.plannedPath !== void 0;
|
|
377
|
+
if (stepped ? r.width > planned.width : planned.width !== r.width) return false;
|
|
335
378
|
if (planned.role === "video") {
|
|
336
379
|
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;
|
|
380
|
+
const sameSearch = search === void 0 ? r.search === void 0 && r.crf === planned.crf : r.search?.metric === search.metric && r.search.target === search.target && r.search.floor === search.floor && r.search.maxBytes === search.maxBytes;
|
|
338
381
|
return sameSearch && r.codec === planned.codec && r.speed === planned.speed && r.maxWidth === planned.maxWidth && r.maxFps === planned.maxFps && r.audio === planned.audio;
|
|
339
382
|
}
|
|
340
383
|
return r.quality === planned.quality && r.effort === planned.effort && r.at === planned.at;
|
|
@@ -382,7 +425,7 @@ async function runVideoItem(item, ctx) {
|
|
|
382
425
|
if (!encoderFor(codec, caps)) unavailable.add(codec);
|
|
383
426
|
}
|
|
384
427
|
const entry = ctx.manifest?.videos[item.source];
|
|
385
|
-
const recorded = new Map((entry?.outputs ?? []).map((o) => [o.path, o]));
|
|
428
|
+
const recorded = new Map((entry?.outputs ?? []).map((o) => [o.search?.plannedPath ?? o.path, o]));
|
|
386
429
|
const sameSource = entry?.sha256 === item.sourceSha256;
|
|
387
430
|
const probe = probeFromItem(item);
|
|
388
431
|
const versions = await ctx.engine.versions();
|
|
@@ -397,7 +440,9 @@ async function runVideoItem(item, ctx) {
|
|
|
397
440
|
const searchable = (metric) => metric === "vmaf" ? caps.libvmaf : ctx.quality !== void 0;
|
|
398
441
|
const retrySearch = /* @__PURE__ */ new Set();
|
|
399
442
|
for (const r of entry?.outputs ?? []) {
|
|
400
|
-
if (r.search && r.score === void 0 && searchable(r.search.metric))
|
|
443
|
+
if (r.search && r.score === void 0 && searchable(r.search.metric)) {
|
|
444
|
+
retrySearch.add(r.search.plannedPath ?? r.path);
|
|
445
|
+
}
|
|
401
446
|
}
|
|
402
447
|
const noForce = !ctx.force;
|
|
403
448
|
if (noForce && retrySearch.size === 0) {
|
|
@@ -423,7 +468,7 @@ async function runVideoItem(item, ctx) {
|
|
|
423
468
|
}
|
|
424
469
|
const prior = recorded.get(planned.path);
|
|
425
470
|
if (noForce && sameSource && prior && prior.status === "written" && !retrySearch.has(planned.path) && sameParams(planned, prior)) {
|
|
426
|
-
const actual = await hashOutput(
|
|
471
|
+
const actual = await hashOutput(prior.path);
|
|
427
472
|
if (actual !== null && actual === prior.sha256) {
|
|
428
473
|
record(fromRecorded(prior));
|
|
429
474
|
continue;
|
|
@@ -536,7 +581,7 @@ async function existingPolicy(path, newHash, ctx) {
|
|
|
536
581
|
}
|
|
537
582
|
var SEARCH_SHARE = 0.4;
|
|
538
583
|
var EXPECTED_PROBES = 7;
|
|
539
|
-
async function searchCrf(planned, item, sourcePath, probe, encoder, hasLibvmaf, ctx) {
|
|
584
|
+
async function searchCrf(planned, item, sourcePath, probe, encoder, hasLibvmaf, ctx, maxWidth) {
|
|
540
585
|
const search = planned.search;
|
|
541
586
|
if (!search) return { kind: "fixed", skipped: false };
|
|
542
587
|
const skip = (why) => {
|
|
@@ -554,7 +599,7 @@ async function searchCrf(planned, item, sourcePath, probe, encoder, hasLibvmaf,
|
|
|
554
599
|
return skip(`the quality scorer could not start (${message(error)})`);
|
|
555
600
|
}
|
|
556
601
|
}
|
|
557
|
-
const { width, height } = scoreGeometry(probe,
|
|
602
|
+
const { width, height } = scoreGeometry(probe, maxWidth);
|
|
558
603
|
if (search.metric === "ssimulacra2" && (width < 8 || height < 8)) {
|
|
559
604
|
return skip("the video is too small to score");
|
|
560
605
|
}
|
|
@@ -568,7 +613,7 @@ async function searchCrf(planned, item, sourcePath, probe, encoder, hasLibvmaf,
|
|
|
568
613
|
codec: planned.codec,
|
|
569
614
|
encoder,
|
|
570
615
|
speed: planned.speed,
|
|
571
|
-
maxWidth
|
|
616
|
+
maxWidth,
|
|
572
617
|
maxFps: planned.maxFps,
|
|
573
618
|
startCrf: planned.crf,
|
|
574
619
|
signal: ctx.signal,
|
|
@@ -589,35 +634,116 @@ async function searchCrf(planned, item, sourcePath, probe, encoder, hasLibvmaf,
|
|
|
589
634
|
reason: `${search.metric} reached only ${outcome.bestScore.toFixed(1)} at crf ${CRF_RANGE[planned.codec].lo}, below the target ${search.target}`
|
|
590
635
|
};
|
|
591
636
|
}
|
|
592
|
-
|
|
637
|
+
const found = {
|
|
593
638
|
kind: "found",
|
|
594
639
|
crf: outcome.crf,
|
|
640
|
+
qualityCrf: outcome.crf,
|
|
595
641
|
score: outcome.score,
|
|
596
642
|
scorer: search.metric === "vmaf" ? "libvmaf" : scorer.kind,
|
|
597
643
|
searcher
|
|
598
644
|
};
|
|
645
|
+
if (search.maxBytes === void 0) return found;
|
|
646
|
+
const cap = search.maxBytes;
|
|
647
|
+
const audioBytes = planned.audio === "keep" && probe.hasAudio ? Math.round(Number.parseInt(planned.audioBitrate, 10) * 1e3 / 8 * probe.durationSec) : 0;
|
|
648
|
+
const range = CRF_RANGE[planned.codec];
|
|
649
|
+
const fit = await fitCrfToSize({
|
|
650
|
+
lo: outcome.crf,
|
|
651
|
+
hi: range.hi,
|
|
652
|
+
maxBytes: Math.max(0, cap - audioBytes),
|
|
653
|
+
sizeAt: (crf) => searcher.sizeAt(crf)
|
|
654
|
+
});
|
|
655
|
+
if (!fit.ok) {
|
|
656
|
+
return {
|
|
657
|
+
kind: "too-big",
|
|
658
|
+
reason: `about ${formatBytes(fit.bytes + audioBytes)} at crf ${fit.crf}, over the ${formatBytes(cap)} cap`
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
if (fit.crf === outcome.crf) return found;
|
|
662
|
+
const floor = search.floor ?? search.target;
|
|
663
|
+
const score = await searcher.score(fit.crf);
|
|
664
|
+
if (score < floor) {
|
|
665
|
+
return {
|
|
666
|
+
kind: "too-big",
|
|
667
|
+
reason: `fitting ${formatBytes(cap)} needs crf ${fit.crf}, which scores ${score.toFixed(1)}, below the floor ${floor}`
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
return { ...found, crf: fit.crf, score };
|
|
599
671
|
}
|
|
600
672
|
async function makeVideo(planned, item, sourcePath, probe, encoder, hasLibvmaf, ctx, encoderLabel) {
|
|
601
|
-
const
|
|
673
|
+
const unmet = (reason) => {
|
|
674
|
+
ctx.warnings.add(`${planned.path}: ${reason}; nothing was written`);
|
|
675
|
+
return {
|
|
676
|
+
output: { ...blank(planned, "unmet"), codec: planned.codec, error: reason },
|
|
677
|
+
wrote: false
|
|
678
|
+
};
|
|
679
|
+
};
|
|
680
|
+
let width = planned.width;
|
|
681
|
+
let maxWidth = planned.maxWidth;
|
|
682
|
+
const tried = [];
|
|
683
|
+
for (; ; ) {
|
|
684
|
+
const attempt = await attemptWidth(
|
|
685
|
+
planned,
|
|
686
|
+
item,
|
|
687
|
+
sourcePath,
|
|
688
|
+
probe,
|
|
689
|
+
encoder,
|
|
690
|
+
hasLibvmaf,
|
|
691
|
+
ctx,
|
|
692
|
+
encoderLabel,
|
|
693
|
+
width,
|
|
694
|
+
maxWidth
|
|
695
|
+
);
|
|
696
|
+
if (attempt.kind === "done") return attempt;
|
|
697
|
+
tried.push(`${width}px: ${attempt.reason}`);
|
|
698
|
+
const next = stepWidth(planned.width, width);
|
|
699
|
+
if (next === null) {
|
|
700
|
+
const half = Math.ceil(planned.width / 2);
|
|
701
|
+
return unmet(
|
|
702
|
+
`the size cap could not be met at ${width}px, the narrowest allowed (half of ${planned.width}px is ${half}px): ${tried.join("; ")}`
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
width = next;
|
|
706
|
+
maxWidth = next;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
async function attemptWidth(planned, item, sourcePath, probe, encoder, hasLibvmaf, ctx, encoderLabel, width, maxWidth) {
|
|
710
|
+
const stepped = width !== planned.width;
|
|
711
|
+
const path = stepped ? planned.path.replace(
|
|
712
|
+
new RegExp(`-${planned.width}\\.${planned.codec}\\.mp4$`),
|
|
713
|
+
`-${width}.${planned.codec}.mp4`
|
|
714
|
+
) : planned.path;
|
|
715
|
+
const searched = await searchCrf(
|
|
716
|
+
planned,
|
|
717
|
+
item,
|
|
718
|
+
sourcePath,
|
|
719
|
+
probe,
|
|
720
|
+
encoder,
|
|
721
|
+
hasLibvmaf,
|
|
722
|
+
ctx,
|
|
723
|
+
maxWidth
|
|
724
|
+
);
|
|
602
725
|
if (searched.kind === "unmet") {
|
|
603
|
-
ctx.warnings.add(`${
|
|
726
|
+
ctx.warnings.add(`${path}: ${searched.reason}; nothing was written`);
|
|
604
727
|
return {
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
codec: planned.codec,
|
|
608
|
-
error: searched.reason
|
|
609
|
-
},
|
|
728
|
+
kind: "done",
|
|
729
|
+
output: { ...blank(planned, "unmet"), codec: planned.codec, error: searched.reason },
|
|
610
730
|
wrote: false
|
|
611
731
|
};
|
|
612
732
|
}
|
|
733
|
+
if (searched.kind === "too-big") return { kind: "too-big", reason: searched.reason };
|
|
613
734
|
const found = searched.kind === "found" ? searched : null;
|
|
735
|
+
const search = planned.search;
|
|
736
|
+
const cap = search?.maxBytes;
|
|
614
737
|
const range = CRF_RANGE[planned.codec];
|
|
615
738
|
let crf = found ? found.crf : planned.crf;
|
|
616
739
|
let score = found?.score;
|
|
617
740
|
const base = found ? SEARCH_SHARE : 0;
|
|
618
|
-
const
|
|
741
|
+
const capped = found !== null && cap !== void 0;
|
|
742
|
+
const triedCrfs = /* @__PURE__ */ new Set();
|
|
743
|
+
const temp = await ctx.fs.reserveTemp(path);
|
|
619
744
|
try {
|
|
620
745
|
for (let bumps = 0; ; bumps++) {
|
|
746
|
+
triedCrfs.add(crf);
|
|
621
747
|
await ctx.engine.encode(
|
|
622
748
|
{
|
|
623
749
|
source: sourcePath,
|
|
@@ -625,7 +751,7 @@ async function makeVideo(planned, item, sourcePath, probe, encoder, hasLibvmaf,
|
|
|
625
751
|
encoder,
|
|
626
752
|
crf,
|
|
627
753
|
speed: planned.speed,
|
|
628
|
-
maxWidth
|
|
754
|
+
maxWidth,
|
|
629
755
|
maxFps: planned.maxFps,
|
|
630
756
|
audio: planned.audio,
|
|
631
757
|
audioBitrate: planned.audioBitrate,
|
|
@@ -634,61 +760,83 @@ async function makeVideo(planned, item, sourcePath, probe, encoder, hasLibvmaf,
|
|
|
634
760
|
(fraction) => ctx.onProgress?.(item.source, planned.path, base + (1 - base) * fraction),
|
|
635
761
|
ctx.signal ?? new AbortController().signal
|
|
636
762
|
);
|
|
763
|
+
const { size: encodedBytes } = await stat(temp);
|
|
764
|
+
if (capped && encodedBytes > cap) {
|
|
765
|
+
const nextCrf = crf + CONFIRM_STEP;
|
|
766
|
+
if (bumps >= MAX_CONFIRM_BUMPS || nextCrf > range.hi || triedCrfs.has(nextCrf)) {
|
|
767
|
+
await ctx.fs.discardTemp(temp);
|
|
768
|
+
return {
|
|
769
|
+
kind: "too-big",
|
|
770
|
+
reason: `${formatBytes(encodedBytes)} on the full encode at crf ${crf}, over the ${formatBytes(cap)} cap`
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
crf = nextCrf;
|
|
774
|
+
continue;
|
|
775
|
+
}
|
|
637
776
|
if (!found || found.searcher.whole) break;
|
|
638
777
|
const measured = await found.searcher.confirm(temp, crf);
|
|
639
|
-
|
|
778
|
+
const required = capped && crf > found.qualityCrf ? search.floor ?? search.target : search.target;
|
|
779
|
+
if (measured >= required) {
|
|
640
780
|
score = measured;
|
|
641
781
|
break;
|
|
642
782
|
}
|
|
643
|
-
|
|
783
|
+
const lower = crf - CONFIRM_STEP;
|
|
784
|
+
if (bumps >= MAX_CONFIRM_BUMPS || lower < range.lo || triedCrfs.has(lower)) {
|
|
644
785
|
await ctx.fs.discardTemp(temp);
|
|
645
|
-
const reason = `${
|
|
646
|
-
|
|
786
|
+
const reason = `${search.metric} was ${measured.toFixed(1)} on the full encode at crf ${crf}, below ${capped && crf > found.qualityCrf ? "the floor" : "the target"} ${required}`;
|
|
787
|
+
if (capped) return { kind: "too-big", reason };
|
|
788
|
+
ctx.warnings.add(`${path}: ${reason}; nothing was written`);
|
|
647
789
|
return {
|
|
790
|
+
kind: "done",
|
|
648
791
|
output: { ...blank(planned, "unmet"), codec: planned.codec, error: reason },
|
|
649
792
|
wrote: false
|
|
650
793
|
};
|
|
651
794
|
}
|
|
652
|
-
crf
|
|
795
|
+
crf = lower;
|
|
653
796
|
}
|
|
654
797
|
const { size } = await stat(temp);
|
|
655
798
|
const video = await readVideoCodecString(temp).catch(() => null);
|
|
656
799
|
const codecs = video ? planned.audio === "keep" && probe.hasAudio ? `${video}, mp4a.40.2` : video : void 0;
|
|
657
800
|
const fps = planned.maxFps !== null ? Math.min(probe.fps, planned.maxFps) : probe.fps;
|
|
658
801
|
const result = {
|
|
659
|
-
path
|
|
802
|
+
path,
|
|
660
803
|
role: "video",
|
|
661
804
|
format: "mp4",
|
|
662
805
|
codec: planned.codec,
|
|
663
|
-
width
|
|
664
|
-
height: evenHeight(probe,
|
|
806
|
+
width,
|
|
807
|
+
height: evenHeight(probe, width),
|
|
665
808
|
fps: Math.round(fps * 100) / 100,
|
|
666
809
|
bytes: size,
|
|
667
810
|
crf,
|
|
668
811
|
speed: planned.speed,
|
|
812
|
+
// The plan's own values: they are what the cache compares, whatever width was reached.
|
|
669
813
|
maxWidth: planned.maxWidth,
|
|
670
814
|
maxFps: planned.maxFps,
|
|
671
815
|
audio: planned.audio,
|
|
672
816
|
...codecs ? { codecs } : {},
|
|
673
817
|
// Recorded even when the search could not run, so the cache recognises the output; a
|
|
674
818
|
// missing score is what marks it as worth searching once the metric is available.
|
|
675
|
-
...
|
|
819
|
+
...search ? { search: { ...search, ...stepped ? { plannedPath: planned.path } : {} } } : {},
|
|
676
820
|
...score !== void 0 && found ? { score: Math.round(score * 100) / 100, scorer: found.scorer } : {},
|
|
677
821
|
encoder: encoderLabel(encoder),
|
|
678
822
|
status: "written"
|
|
679
823
|
};
|
|
680
824
|
if (size >= item.sourceBytes) {
|
|
681
825
|
await ctx.fs.discardTemp(temp);
|
|
682
|
-
return { output: { ...result, status: "already-optimal" }, wrote: true };
|
|
826
|
+
return { kind: "done", output: { ...result, status: "already-optimal" }, wrote: true };
|
|
683
827
|
}
|
|
684
828
|
const digest = await sha256File(temp);
|
|
685
|
-
const verdict = await existingPolicy(
|
|
829
|
+
const verdict = await existingPolicy(path, digest, ctx);
|
|
686
830
|
if (verdict) {
|
|
687
831
|
await ctx.fs.discardTemp(temp);
|
|
688
|
-
return {
|
|
832
|
+
return {
|
|
833
|
+
kind: "done",
|
|
834
|
+
output: { ...result, sha256: digest, status: verdict },
|
|
835
|
+
wrote: false
|
|
836
|
+
};
|
|
689
837
|
}
|
|
690
|
-
await ctx.fs.commitTemp(temp,
|
|
691
|
-
return { output: { ...result, sha256: digest }, wrote: true };
|
|
838
|
+
await ctx.fs.commitTemp(temp, path);
|
|
839
|
+
return { kind: "done", output: { ...result, sha256: digest }, wrote: true };
|
|
692
840
|
} catch (error) {
|
|
693
841
|
await ctx.fs.discardTemp(temp);
|
|
694
842
|
throw error;
|
|
@@ -807,7 +955,7 @@ function crop(image, w) {
|
|
|
807
955
|
// src/core/quality/search.ts
|
|
808
956
|
var MAX_BUMPS = 8;
|
|
809
957
|
var STEP = 0.9;
|
|
810
|
-
var
|
|
958
|
+
var MIN_WIDTH_FRACTION2 = 0.5;
|
|
811
959
|
var even = (n) => Math.max(2, Math.floor(n / 2) * 2);
|
|
812
960
|
var kb = (bytes) => `${(bytes / 1024).toFixed(1)} KB`;
|
|
813
961
|
var Cancelled = class extends Error {
|
|
@@ -959,7 +1107,7 @@ async function searchQuality(request) {
|
|
|
959
1107
|
const widthNotes = [];
|
|
960
1108
|
while (!last.ok && last.kind === "size") {
|
|
961
1109
|
const next = even(width * STEP);
|
|
962
|
-
const halfWidth = Math.ceil(request.width *
|
|
1110
|
+
const halfWidth = Math.ceil(request.width * MIN_WIDTH_FRACTION2);
|
|
963
1111
|
if (next <= request.minWidth || next < halfWidth || next >= width) {
|
|
964
1112
|
const stoppedAt = next <= request.minWidth ? `the next planned width (${request.minWidth}px)` : `half the planned width (${halfWidth}px)`;
|
|
965
1113
|
return {
|
|
@@ -1386,7 +1534,12 @@ function mergeVideoItem(old, item, plannedPaths, publicPath) {
|
|
|
1386
1534
|
const merged = new Map(produced);
|
|
1387
1535
|
for (const previous of old?.outputs ?? []) {
|
|
1388
1536
|
if (merged.has(previous.path)) continue;
|
|
1389
|
-
|
|
1537
|
+
const standsFor = previous.search?.plannedPath;
|
|
1538
|
+
if (standsFor !== void 0 && merged.has(standsFor)) {
|
|
1539
|
+
const { plannedPath: _superseded, ...search } = previous.search;
|
|
1540
|
+
void _superseded;
|
|
1541
|
+
merged.set(previous.path, { ...previous, search });
|
|
1542
|
+
} else if (plannedPaths.has(standsFor ?? previous.path) && !sameSource) {
|
|
1390
1543
|
merged.set(previous.path, { ...previous, status: "stale" });
|
|
1391
1544
|
} else {
|
|
1392
1545
|
merged.set(previous.path, previous);
|
|
@@ -1926,7 +2079,7 @@ function formatPlan(plan, cache) {
|
|
|
1926
2079
|
for (const out of video.outputs) {
|
|
1927
2080
|
if (out.role === "video") {
|
|
1928
2081
|
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}`;
|
|
2082
|
+
const mode = out.search ? `${out.search.metric} >= ${out.search.target}${out.search.maxBytes !== void 0 ? `, <= ${formatBytes(out.search.maxBytes)}` : ""} (starts at crf ${out.crf})` : `crf ${out.crf}`;
|
|
1930
2083
|
lines.push(` ${out.path} (${out.codec}, ${out.width}w, ${mode}, ${audio})`);
|
|
1931
2084
|
} else {
|
|
1932
2085
|
lines.push(` ${out.path} (poster, ${out.width}w)`);
|
|
@@ -1978,7 +2131,8 @@ function searchNote(out) {
|
|
|
1978
2131
|
}
|
|
1979
2132
|
function videoSearchNote(out) {
|
|
1980
2133
|
if (!out.search || out.score === void 0 || out.crf === void 0) return "";
|
|
1981
|
-
|
|
2134
|
+
const stepped = out.search.plannedPath ? " \xB7 width stepped down" : "";
|
|
2135
|
+
return ` \xB7 crf ${out.crf} \xB7 ${out.search.metric} ${out.score.toFixed(1)}${stepped}`;
|
|
1982
2136
|
}
|
|
1983
2137
|
function videoOutputLine(out, sourceBytes) {
|
|
1984
2138
|
const label = out.role === "poster" ? "poster" : out.codec ?? "mp4";
|
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
import {
|
|
3
3
|
outputWidth,
|
|
4
4
|
toPosix
|
|
5
|
-
} from "./chunk-
|
|
5
|
+
} from "./chunk-L5HLKZDW.js";
|
|
6
6
|
import {
|
|
7
7
|
BUILTIN_POLICY,
|
|
8
8
|
getVideoPreset
|
|
9
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-PE5NKZCH.js";
|
|
10
10
|
import {
|
|
11
11
|
BytecutError
|
|
12
|
-
} from "./chunk-
|
|
12
|
+
} from "./chunk-KJK7XGWA.js";
|
|
13
13
|
|
|
14
14
|
// src/core/classify/classify.ts
|
|
15
15
|
var FLAT_COLOR_LIMIT = 64;
|
|
@@ -399,7 +399,7 @@ var orphanedOutput = {
|
|
|
399
399
|
if (!item || item.skip) continue;
|
|
400
400
|
const wanted = new Set(item.outputs.map((o) => o.path));
|
|
401
401
|
for (const out of entry.outputs) {
|
|
402
|
-
if (!wanted.has(out.path)) {
|
|
402
|
+
if (!wanted.has(out.path) && !wanted.has(out.search?.plannedPath ?? "")) {
|
|
403
403
|
findings.push({
|
|
404
404
|
ruleId: ID10,
|
|
405
405
|
severity: "info",
|
|
@@ -581,20 +581,29 @@ async function checkCache(item, entry, hashOutput) {
|
|
|
581
581
|
}
|
|
582
582
|
return { kind: "hit" };
|
|
583
583
|
}
|
|
584
|
+
function recordedVideoByPlannedPath(entry) {
|
|
585
|
+
return new Map(entry.outputs.map((o) => [o.search?.plannedPath ?? o.path, o]));
|
|
586
|
+
}
|
|
584
587
|
function sameVideoParams(planned, r) {
|
|
585
|
-
if (planned.role !== r.role
|
|
588
|
+
if (planned.role !== r.role) return false;
|
|
586
589
|
if (planned.role === "video") {
|
|
587
590
|
const search = planned.search;
|
|
588
|
-
|
|
591
|
+
if (r.search?.plannedPath !== void 0) {
|
|
592
|
+
if (r.search.plannedPath !== planned.path || r.width > planned.width) return false;
|
|
593
|
+
} else if (planned.path !== r.path || planned.width !== r.width) {
|
|
594
|
+
return false;
|
|
595
|
+
}
|
|
596
|
+
const sameSearch2 = search === void 0 ? r.search === void 0 && r.crf === planned.crf : r.search?.metric === search.metric && r.search.target === search.target && r.search.floor === search.floor && r.search.maxBytes === search.maxBytes;
|
|
589
597
|
return sameSearch2 && r.codec === planned.codec && r.speed === planned.speed && r.maxWidth === planned.maxWidth && r.maxFps === planned.maxFps && r.audio === planned.audio;
|
|
590
598
|
}
|
|
599
|
+
if (planned.path !== r.path || planned.width !== r.width) return false;
|
|
591
600
|
return r.quality === planned.quality && r.effort === planned.effort && r.at === planned.at;
|
|
592
601
|
}
|
|
593
602
|
async function checkVideoCache(item, entry, hashOutput, unavailable = /* @__PURE__ */ new Set()) {
|
|
594
603
|
if (!entry) return { kind: "miss", reason: "new" };
|
|
595
604
|
if (entry.sha256 !== item.sourceSha256) return { kind: "miss", reason: "source-changed" };
|
|
596
605
|
if (entry.preset !== item.preset) return { kind: "miss", reason: "outputs-differ" };
|
|
597
|
-
const recorded =
|
|
606
|
+
const recorded = recordedVideoByPlannedPath(entry);
|
|
598
607
|
const wanted = item.outputs.filter((o) => !(o.role === "video" && unavailable.has(o.codec)));
|
|
599
608
|
for (const planned of wanted) {
|
|
600
609
|
const r = recorded.get(planned.path);
|
|
@@ -604,7 +613,7 @@ async function checkVideoCache(item, entry, hashOutput, unavailable = /* @__PURE
|
|
|
604
613
|
const r = recorded.get(planned.path);
|
|
605
614
|
if (r.status === "stale") return { kind: "miss", reason: "output-stale" };
|
|
606
615
|
if (r.status !== "written") continue;
|
|
607
|
-
const actual = await hashOutput(
|
|
616
|
+
const actual = await hashOutput(r.path);
|
|
608
617
|
if (actual === null) return { kind: "miss", reason: "output-missing" };
|
|
609
618
|
if (actual !== r.sha256) return { kind: "miss", reason: "output-modified" };
|
|
610
619
|
}
|
|
@@ -1032,7 +1041,7 @@ var presetSchema = z.object({
|
|
|
1032
1041
|
maxFps: z.number().int().min(1).nullable().optional(),
|
|
1033
1042
|
codecs: z.array(z.enum(["av1", "h264"])).min(1).optional(),
|
|
1034
1043
|
crf: crfByCodec.optional(),
|
|
1035
|
-
// perceptual search: images and video
|
|
1044
|
+
// perceptual search: images and video; `floor` and `maxSize` cap a file's size (both)
|
|
1036
1045
|
ssimulacra2: z.number().min(1).max(100).optional(),
|
|
1037
1046
|
vmaf: z.number().min(1).max(100).optional(),
|
|
1038
1047
|
floor: z.number().min(1).max(100).optional(),
|
|
@@ -1050,16 +1059,18 @@ var presetSchema = z.object({
|
|
|
1050
1059
|
if (preset.vmaf !== void 0 && preset.ssimulacra2 !== void 0) {
|
|
1051
1060
|
say("vmaf", "cannot be combined with `ssimulacra2`: a preset searches for one metric");
|
|
1052
1061
|
}
|
|
1053
|
-
|
|
1062
|
+
const target = preset.ssimulacra2 ?? preset.vmaf;
|
|
1063
|
+
const metric = preset.ssimulacra2 !== void 0 ? "ssimulacra2" : "vmaf";
|
|
1064
|
+
if (preset.floor !== void 0 && target === void 0) {
|
|
1054
1065
|
say(
|
|
1055
1066
|
"floor",
|
|
1056
|
-
"needs `ssimulacra2
|
|
1067
|
+
"needs `ssimulacra2` (or `vmaf` on a video preset): it is the lowest score allowed when `maxSize` forces quality down"
|
|
1057
1068
|
);
|
|
1058
1069
|
}
|
|
1059
|
-
if (preset.maxSize !== void 0 &&
|
|
1070
|
+
if (preset.maxSize !== void 0 && target === void 0) {
|
|
1060
1071
|
say(
|
|
1061
1072
|
"maxSize",
|
|
1062
|
-
"needs `ssimulacra2
|
|
1073
|
+
"needs `ssimulacra2` (or `vmaf` on a video preset), so there is a quality target to hold while fitting the size"
|
|
1063
1074
|
);
|
|
1064
1075
|
}
|
|
1065
1076
|
if (preset.maxSize !== void 0 && preset.floor === void 0) {
|
|
@@ -1068,10 +1079,10 @@ var presetSchema = z.object({
|
|
|
1068
1079
|
"is required with `maxSize`: how far quality may drop to fit is a tradeoff to state, not a default"
|
|
1069
1080
|
);
|
|
1070
1081
|
}
|
|
1071
|
-
if (preset.floor !== void 0 &&
|
|
1072
|
-
say("floor", `must not exceed \`
|
|
1082
|
+
if (preset.floor !== void 0 && target !== void 0 && preset.floor > target) {
|
|
1083
|
+
say("floor", `must not exceed \`${metric}\` (${target})`);
|
|
1073
1084
|
}
|
|
1074
|
-
if (preset.floor !== void 0 && preset.maxSize === void 0 &&
|
|
1085
|
+
if (preset.floor !== void 0 && preset.maxSize === void 0 && target !== void 0) {
|
|
1075
1086
|
say("floor", "only applies with `maxSize`");
|
|
1076
1087
|
}
|
|
1077
1088
|
});
|
|
@@ -3,10 +3,10 @@ import {
|
|
|
3
3
|
ffmpegDirOf,
|
|
4
4
|
probeFfmpeg,
|
|
5
5
|
resolveBinary
|
|
6
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-PE5NKZCH.js";
|
|
7
7
|
import {
|
|
8
8
|
BytecutError
|
|
9
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-KJK7XGWA.js";
|
|
10
10
|
|
|
11
11
|
// src/core/posix.ts
|
|
12
12
|
import { sep } from "path";
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
detectProject,
|
|
5
5
|
hint,
|
|
6
6
|
matchesGlob
|
|
7
|
-
} from "./chunk-
|
|
7
|
+
} from "./chunk-KJK7XGWA.js";
|
|
8
8
|
|
|
9
9
|
// src/core/presets/photo.ts
|
|
10
10
|
var OTHER_ENCODERS = {
|
|
@@ -146,9 +146,7 @@ var IMAGE_ONLY = [
|
|
|
146
146
|
"quality",
|
|
147
147
|
"effort",
|
|
148
148
|
"lossless",
|
|
149
|
-
"priority"
|
|
150
|
-
"floor",
|
|
151
|
-
"maxSize"
|
|
149
|
+
"priority"
|
|
152
150
|
];
|
|
153
151
|
var VIDEO_ONLY = ["audio", "maxWidth", "maxFps", "codecs", "crf"];
|
|
154
152
|
var METRICS = ["ssimulacra2", "vmaf"];
|
|
@@ -235,12 +233,24 @@ function applyVideo(name, base, def) {
|
|
|
235
233
|
if (def.audio) next.audio = def.audio;
|
|
236
234
|
if (def.maxWidth !== void 0) next.maxWidth = def.maxWidth;
|
|
237
235
|
if (def.maxFps !== void 0) next.maxFps = def.maxFps;
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
236
|
+
const cap = {
|
|
237
|
+
...def.floor !== void 0 ? { floor: def.floor } : {},
|
|
238
|
+
...def.maxSize !== void 0 ? { maxBytes: def.maxSize } : {}
|
|
239
|
+
};
|
|
240
|
+
if (def.ssimulacra2 !== void 0) {
|
|
241
|
+
next.search = { metric: "ssimulacra2", target: def.ssimulacra2, ...cap };
|
|
242
|
+
}
|
|
243
|
+
if (def.vmaf !== void 0) next.search = { metric: "vmaf", target: def.vmaf, ...cap };
|
|
241
244
|
return next;
|
|
242
245
|
}
|
|
243
|
-
var CHANGE_KEYS = [
|
|
246
|
+
var CHANGE_KEYS = [
|
|
247
|
+
...IMAGE_ONLY,
|
|
248
|
+
...VIDEO_ONLY,
|
|
249
|
+
...METRICS,
|
|
250
|
+
"floor",
|
|
251
|
+
"maxSize",
|
|
252
|
+
"target"
|
|
253
|
+
];
|
|
244
254
|
function buildCatalog(defs) {
|
|
245
255
|
const image = { ...BUILTIN_CATALOG.image };
|
|
246
256
|
const video = { ...BUILTIN_CATALOG.video };
|