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.
Files changed (27) hide show
  1. package/README.md +18 -4
  2. package/dist/{audit-EBKL6L65.js → audit-RKJNHNDZ.js} +11 -11
  3. package/dist/{check-XTTU5SCZ.js → check-MN7R7XOM.js} +10 -10
  4. package/dist/{chunk-IZ4JEL3Q.js → chunk-4AM2CCML.js} +1 -1
  5. package/dist/{chunk-4FIB6VNC.js → chunk-7RHHOWQT.js} +1 -1
  6. package/dist/{chunk-KN5QOB7R.js → chunk-ABHEWNYQ.js} +4 -4
  7. package/dist/{chunk-TCQFQ354.js → chunk-CSDZGNUN.js} +14 -3
  8. package/dist/{chunk-WI4ZO7GP.js → chunk-DZLFVN4G.js} +14 -11
  9. package/dist/{chunk-PQRBLHCC.js → chunk-EM7JMQND.js} +1 -1
  10. package/dist/{chunk-HLBNRB3S.js → chunk-GNW3OK5U.js} +198 -44
  11. package/dist/{chunk-RZ2V4PCS.js → chunk-JMMXC5UA.js} +3 -3
  12. package/dist/{chunk-FY53B4NB.js → chunk-KJK7XGWA.js} +24 -13
  13. package/dist/{chunk-QOG2F4SF.js → chunk-L5HLKZDW.js} +2 -2
  14. package/dist/{chunk-MV6J66AP.js → chunk-MUE3L5XT.js} +1 -1
  15. package/dist/{chunk-SKANYRVT.js → chunk-NOTYXNMY.js} +1 -1
  16. package/dist/{chunk-3PF6BBFK.js → chunk-PE5NKZCH.js} +18 -8
  17. package/dist/{chunk-S3FQYUTI.js → chunk-SMR2764T.js} +1 -1
  18. package/dist/{chunk-7DHE25UE.js → chunk-UZ3Z2T5I.js} +1 -1
  19. package/dist/cli.js +22 -22
  20. package/dist/{compare-MPPGTUC5.js → compare-XRMDMCZL.js} +8 -8
  21. package/dist/{doctor-PWCUEBD5.js → doctor-YYTLIOVG.js} +5 -5
  22. package/dist/{explain-LKOB7FRT.js → explain-GZWGHYRY.js} +12 -7
  23. package/dist/{init-CWDWVJ55.js → init-3IQAWWBW.js} +4 -4
  24. package/dist/{optimize-TLJG7UYF.js → optimize-BDPL4Y2Z.js} +11 -11
  25. package/dist/{snippet-IIPVF6PS.js → snippet-6P7CMROL.js} +7 -7
  26. package/dist/{tui-6WSMCHZS.js → tui-ETSPQFXB.js} +29 -17
  27. package/package.json +1 -1
@@ -7,7 +7,7 @@ import {
7
7
  MANIFEST_VERSION,
8
8
  parseManifest,
9
9
  serializeManifest
10
- } from "./chunk-TCQFQ354.js";
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-RZ2V4PCS.js";
19
+ } from "./chunk-JMMXC5UA.js";
20
20
  import {
21
21
  AbortedError,
22
22
  outputWidth
23
- } from "./chunk-QOG2F4SF.js";
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-3PF6BBFK.js";
29
+ } from "./chunk-PE5NKZCH.js";
30
30
  import {
31
31
  SafeFs
32
- } from "./chunk-IZ4JEL3Q.js";
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-FY53B4NB.js";
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 || planned.width !== r.width) return false;
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)) retrySearch.add(r.path);
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(planned.path);
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, planned.maxWidth);
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: planned.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
- return {
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 searched = await searchCrf(planned, item, sourcePath, probe, encoder, hasLibvmaf, ctx);
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(`${planned.path}: ${searched.reason}; nothing was written`);
726
+ ctx.warnings.add(`${path}: ${searched.reason}; nothing was written`);
604
727
  return {
605
- output: {
606
- ...blank(planned, "unmet"),
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 temp = await ctx.fs.reserveTemp(planned.path);
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: planned.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
- if (measured >= planned.search.target) {
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
- if (bumps >= MAX_CONFIRM_BUMPS || crf - CONFIRM_STEP < range.lo) {
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 = `${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`);
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 -= CONFIRM_STEP;
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: planned.path,
802
+ path,
660
803
  role: "video",
661
804
  format: "mp4",
662
805
  codec: planned.codec,
663
- width: planned.width,
664
- height: evenHeight(probe, planned.width),
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
- ...planned.search ? { search: planned.search } : {},
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(planned.path, digest, ctx);
829
+ const verdict = await existingPolicy(path, digest, ctx);
686
830
  if (verdict) {
687
831
  await ctx.fs.discardTemp(temp);
688
- return { output: { ...result, sha256: digest, status: verdict }, wrote: false };
832
+ return {
833
+ kind: "done",
834
+ output: { ...result, sha256: digest, status: verdict },
835
+ wrote: false
836
+ };
689
837
  }
690
- await ctx.fs.commitTemp(temp, planned.path);
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 MIN_WIDTH_FRACTION = 0.5;
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 * MIN_WIDTH_FRACTION);
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
- if (plannedPaths.has(previous.path) && !sameSource) {
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
- return ` \xB7 crf ${out.crf} \xB7 ${out.search.metric} ${out.score.toFixed(1)}`;
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-QOG2F4SF.js";
5
+ } from "./chunk-L5HLKZDW.js";
6
6
  import {
7
7
  BUILTIN_POLICY,
8
8
  getVideoPreset
9
- } from "./chunk-3PF6BBFK.js";
9
+ } from "./chunk-PE5NKZCH.js";
10
10
  import {
11
11
  BytecutError
12
- } from "./chunk-FY53B4NB.js";
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 || planned.path !== r.path || planned.width !== r.width) return false;
588
+ if (planned.role !== r.role) return false;
586
589
  if (planned.role === "video") {
587
590
  const search = planned.search;
588
- const sameSearch2 = search === void 0 ? r.search === void 0 && r.crf === planned.crf : r.search?.metric === search.metric && r.search.target === search.target;
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 = new Map(entry.outputs.map((o) => [o.path, o]));
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(planned.path);
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 (`floor` and `maxSize` are image-only)
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
- if (preset.floor !== void 0 && preset.ssimulacra2 === void 0) {
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`: it is the lowest score allowed when `maxSize` forces quality down"
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 && preset.ssimulacra2 === void 0) {
1070
+ if (preset.maxSize !== void 0 && target === void 0) {
1060
1071
  say(
1061
1072
  "maxSize",
1062
- "needs `ssimulacra2`, so there is a quality target to hold while fitting the size"
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 && preset.ssimulacra2 !== void 0 && preset.floor > preset.ssimulacra2) {
1072
- say("floor", `must not exceed \`ssimulacra2\` (${preset.ssimulacra2})`);
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 && preset.ssimulacra2 !== 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-3PF6BBFK.js";
6
+ } from "./chunk-PE5NKZCH.js";
7
7
  import {
8
8
  BytecutError
9
- } from "./chunk-FY53B4NB.js";
9
+ } from "./chunk-KJK7XGWA.js";
10
10
 
11
11
  // src/core/posix.ts
12
12
  import { sep } from "path";
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  BytecutError
4
- } from "./chunk-FY53B4NB.js";
4
+ } from "./chunk-KJK7XGWA.js";
5
5
 
6
6
  // src/cli/errors.ts
7
7
  function guarded(run) {
@@ -3,7 +3,7 @@
3
3
  // package.json
4
4
  var package_default = {
5
5
  name: "bytecut",
6
- version: "0.2.1",
6
+ version: "0.3.0",
7
7
  description: "Audit and optimize images and videos in web projects.",
8
8
  type: "module",
9
9
  license: "MIT",
@@ -4,7 +4,7 @@ import {
4
4
  detectProject,
5
5
  hint,
6
6
  matchesGlob
7
- } from "./chunk-FY53B4NB.js";
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
- if (def.ssimulacra2 !== void 0)
239
- next.search = { metric: "ssimulacra2", target: def.ssimulacra2 };
240
- if (def.vmaf !== void 0) next.search = { metric: "vmaf", target: def.vmaf };
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 = [...IMAGE_ONLY, ...VIDEO_ONLY, ...METRICS, "target"];
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 };