bytecut 0.2.0 → 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 +21 -4
  2. package/dist/{audit-LJOGSJS6.js → audit-RKJNHNDZ.js} +11 -11
  3. package/dist/{check-MEBXJBNG.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-5JEA233Z.js → chunk-DZLFVN4G.js} +25 -12
  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-LJWOPIM6.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-SLLQGDDX.js → optimize-BDPL4Y2Z.js} +11 -11
  25. package/dist/{snippet-IIPVF6PS.js → snippet-6P7CMROL.js} +7 -7
  26. package/dist/{tui-ZAEXBY4L.js → tui-ETSPQFXB.js} +51 -22
  27. package/package.json +1 -1
package/README.md CHANGED
@@ -74,15 +74,26 @@ Everything is optional. Config is only needed to make a choice stick. Put it in
74
74
  "maxSize": "150 KB",
75
75
  "floor": 65
76
76
  },
77
- "loop": { "extends": "background-video", "vmaf": 92 }
77
+ "loop": { "extends": "background-video", "vmaf": 92 },
78
+ "promo": {
79
+ "extends": "content-video",
80
+ "ssimulacra2": 70,
81
+ "floor": 55,
82
+ "maxSize": "5 MB"
83
+ }
84
+ },
85
+ "assets": {
86
+ "public/gallery/**": "gallery",
87
+ "public/loops/*.mp4": "loop",
88
+ "public/promo.mp4": "promo"
78
89
  },
79
- "assets": { "public/gallery/**": "gallery", "public/loops/*.mp4": "loop" },
80
90
  "budgets": { "maxFileSize": "250 KB", "maxTotalSize": "5 MB" }
81
91
  }
82
92
  ```
83
93
 
84
94
  - **Presets**: built-in `photo`, `hero`, `icon`, `background-video`, `content-video` and
85
- `gif-to-video`. Add your own with `extends`; the first matching `assets` glob wins.
95
+ `gif-to-video`. Add your own with `extends`; the first matching `assets` glob wins. In the
96
+ TUI, `p` and `/preset` offer image presets for images and video presets for videos and GIFs.
86
97
  - **Size**: `maxEdge` caps the longest side; `widths` lists widths instead (a responsive set).
87
98
  - **Formats**: `jpg` (default), `png`, `webp`, `avif`. A `<picture>` lists AVIF, then WebP, and
88
99
  the JPEG is the `<img>`.
@@ -98,7 +109,10 @@ that still meets it:
98
109
  confirms on the full-size output.
99
110
  - `maxSize` with a required `floor` lowers quality, then steps the width down, to fit a size
100
111
  cap. A constraint that cannot be met writes nothing and fails the run; it is never silently
101
- exceeded.
112
+ exceeded. On video the cap is per output file (AV1 and H.264 each): the CRF is raised until
113
+ the predicted size fits, never past the `floor`, and then the width steps down by a fifth at a
114
+ time, never below half. The finished file is measured, not just predicted. A narrower file is
115
+ named for its real width, e.g. `promo-1228.h264.mp4`.
102
116
  - `vmaf: 92` (video) uses ffmpeg's `libvmaf`. Without it, the preset's fixed quality is used
103
117
  and a warning says so.
104
118
 
@@ -108,6 +122,9 @@ SSIMULACRA2 runs as WebAssembly in worker threads, so no native binary is needed
108
122
 
109
123
  ## Video
110
124
 
125
+ Estimating a large video encodes real samples, so the first scan of a 4K file can take a minute or
126
+ more; the screen shows which sample it is on, and the result is cached by content hash.
127
+
111
128
  bytecut uses **your own** `ffmpeg` and `ffprobe` (`--ffmpeg`, `BYTECUT_FFMPEG`, `ffmpegPath`, or
112
129
  `PATH`). It never downloads or bundles one. Without ffmpeg images work fully and videos are
113
130
  listed as "needs ffmpeg" with the install command for your OS. Without an AV1 encoder you still
@@ -4,16 +4,16 @@ import {
4
4
  } from "./chunk-LTWUPQDT.js";
5
5
  import {
6
6
  audit
7
- } from "./chunk-5JEA233Z.js";
7
+ } from "./chunk-DZLFVN4G.js";
8
8
  import {
9
9
  formatAuditReport
10
- } from "./chunk-HLBNRB3S.js";
10
+ } from "./chunk-GNW3OK5U.js";
11
11
  import "./chunk-3DBJKHOD.js";
12
- import "./chunk-TCQFQ354.js";
12
+ import "./chunk-CSDZGNUN.js";
13
13
  import {
14
14
  sharpImageEngine
15
15
  } from "./chunk-CSFBJ3UH.js";
16
- import "./chunk-RZ2V4PCS.js";
16
+ import "./chunk-JMMXC5UA.js";
17
17
  import {
18
18
  configArg,
19
19
  createVideoEngine,
@@ -21,15 +21,15 @@ import {
21
21
  ffmpegInstallText,
22
22
  ffmpegStatus,
23
23
  noAssetsMessage
24
- } from "./chunk-KN5QOB7R.js";
25
- import "./chunk-S3FQYUTI.js";
26
- import "./chunk-QOG2F4SF.js";
27
- import "./chunk-3PF6BBFK.js";
28
- import "./chunk-IZ4JEL3Q.js";
24
+ } from "./chunk-ABHEWNYQ.js";
25
+ import "./chunk-SMR2764T.js";
26
+ import "./chunk-L5HLKZDW.js";
27
+ import "./chunk-PE5NKZCH.js";
28
+ import "./chunk-4AM2CCML.js";
29
29
  import {
30
30
  guarded
31
- } from "./chunk-MV6J66AP.js";
32
- import "./chunk-FY53B4NB.js";
31
+ } from "./chunk-MUE3L5XT.js";
32
+ import "./chunk-KJK7XGWA.js";
33
33
 
34
34
  // src/cli/commands/audit.ts
35
35
  import { defineCommand } from "citty";
@@ -4,16 +4,16 @@ import {
4
4
  } from "./chunk-LTWUPQDT.js";
5
5
  import {
6
6
  audit
7
- } from "./chunk-5JEA233Z.js";
7
+ } from "./chunk-DZLFVN4G.js";
8
8
  import {
9
9
  formatAuditReport
10
- } from "./chunk-HLBNRB3S.js";
10
+ } from "./chunk-GNW3OK5U.js";
11
11
  import "./chunk-3DBJKHOD.js";
12
- import "./chunk-TCQFQ354.js";
12
+ import "./chunk-CSDZGNUN.js";
13
13
  import {
14
14
  sharpImageEngine
15
15
  } from "./chunk-CSFBJ3UH.js";
16
- import "./chunk-RZ2V4PCS.js";
16
+ import "./chunk-JMMXC5UA.js";
17
17
  import {
18
18
  configArg,
19
19
  createVideoEngine,
@@ -21,22 +21,22 @@ import {
21
21
  ffmpegInstallText,
22
22
  ffmpegStatus,
23
23
  noAssetsMessage
24
- } from "./chunk-KN5QOB7R.js";
24
+ } from "./chunk-ABHEWNYQ.js";
25
25
  import {
26
26
  run
27
- } from "./chunk-S3FQYUTI.js";
27
+ } from "./chunk-SMR2764T.js";
28
28
  import {
29
29
  toPosix
30
- } from "./chunk-QOG2F4SF.js";
31
- import "./chunk-3PF6BBFK.js";
32
- import "./chunk-IZ4JEL3Q.js";
30
+ } from "./chunk-L5HLKZDW.js";
31
+ import "./chunk-PE5NKZCH.js";
32
+ import "./chunk-4AM2CCML.js";
33
33
  import {
34
34
  BytecutError,
35
35
  CONFIG_FILES,
36
36
  formatBytes,
37
37
  percentChange,
38
38
  plural
39
- } from "./chunk-FY53B4NB.js";
39
+ } from "./chunk-KJK7XGWA.js";
40
40
 
41
41
  // src/cli/commands/check.ts
42
42
  import { defineCommand } from "citty";
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  BytecutError,
4
4
  CONFIG_FILES
5
- } from "./chunk-FY53B4NB.js";
5
+ } from "./chunk-KJK7XGWA.js";
6
6
 
7
7
  // src/core/fs-safe.ts
8
8
  import { randomBytes } from "crypto";
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  escapeGlob
4
- } from "./chunk-FY53B4NB.js";
4
+ } from "./chunk-KJK7XGWA.js";
5
5
 
6
6
  // src/core/config/choices.ts
7
7
  import { stringify as stringifyToml } from "smol-toml";
@@ -3,19 +3,19 @@ import {
3
3
  isFile,
4
4
  readOsReleaseId,
5
5
  run
6
- } from "./chunk-S3FQYUTI.js";
6
+ } from "./chunk-SMR2764T.js";
7
7
  import {
8
8
  createFfmpegVideoEngine
9
- } from "./chunk-QOG2F4SF.js";
9
+ } from "./chunk-L5HLKZDW.js";
10
10
  import {
11
11
  installHint,
12
12
  resolveBinary,
13
13
  resolveConfig
14
- } from "./chunk-3PF6BBFK.js";
14
+ } from "./chunk-PE5NKZCH.js";
15
15
  import {
16
16
  BytecutError,
17
17
  loadConfig
18
- } from "./chunk-FY53B4NB.js";
18
+ } from "./chunk-KJK7XGWA.js";
19
19
 
20
20
  // src/cli/shared.ts
21
21
  import { existsSync } from "fs";
@@ -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/core/plan/schema.ts
7
7
  import { z } from "zod";
@@ -49,7 +49,12 @@ var videoOutputSchema = z.discriminatedUnion("role", [
49
49
  maxFps: z.number().positive().nullable(),
50
50
  audio: z.enum(["strip", "keep"]),
51
51
  audioBitrate: z.string().regex(/^\d+k$/),
52
- search: z.object({ metric: z.enum(["ssimulacra2", "vmaf"]), target: z.number().min(1).max(100) }).strict().optional()
52
+ search: z.object({
53
+ metric: z.enum(["ssimulacra2", "vmaf"]),
54
+ target: z.number().min(1).max(100),
55
+ floor: z.number().min(1).max(100).optional(),
56
+ maxBytes: z.number().int().positive().optional()
57
+ }).strict().optional()
53
58
  }).strict(),
54
59
  z.object({
55
60
  role: z.literal("poster"),
@@ -182,7 +187,13 @@ var videoOutputSchema2 = z2.object({
182
187
  codecs: z2.string().optional(),
183
188
  encoder: z2.string(),
184
189
  status: z2.enum(["written", "already-optimal", "stale"]),
185
- search: z2.object({ metric: z2.enum(["ssimulacra2", "vmaf"]), target: z2.number() }).strict().optional(),
190
+ search: z2.object({
191
+ metric: z2.enum(["ssimulacra2", "vmaf"]),
192
+ target: z2.number(),
193
+ floor: z2.number().optional(),
194
+ maxBytes: z2.number().int().optional(),
195
+ plannedPath: z2.string().optional()
196
+ }).strict().optional(),
186
197
  score: z2.number().optional(),
187
198
  scorer: z2.enum(["wasm", "native", "libvmaf"]).optional()
188
199
  }).strict();
@@ -6,38 +6,38 @@ import {
6
6
  sortFindings,
7
7
  walkFiles,
8
8
  withVideos
9
- } from "./chunk-HLBNRB3S.js";
9
+ } from "./chunk-GNW3OK5U.js";
10
10
  import {
11
11
  MANIFEST_FILE,
12
12
  parseManifest
13
- } from "./chunk-TCQFQ354.js";
13
+ } from "./chunk-CSDZGNUN.js";
14
14
  import {
15
15
  ANIMATED_GIF_REASON,
16
16
  buildPlan,
17
17
  classify,
18
18
  classifyVideo,
19
19
  sha256
20
- } from "./chunk-RZ2V4PCS.js";
20
+ } from "./chunk-JMMXC5UA.js";
21
21
  import {
22
22
  isFastStart,
23
23
  toPosix
24
- } from "./chunk-QOG2F4SF.js";
24
+ } from "./chunk-L5HLKZDW.js";
25
25
  import {
26
26
  BUILTIN_POLICY,
27
27
  getImagePreset,
28
28
  pickAv1Encoder,
29
29
  rootPreset
30
- } from "./chunk-3PF6BBFK.js";
30
+ } from "./chunk-PE5NKZCH.js";
31
31
  import {
32
32
  SafeFs
33
- } from "./chunk-IZ4JEL3Q.js";
33
+ } from "./chunk-4AM2CCML.js";
34
34
  import {
35
35
  ALL_RULES,
36
36
  checkCache,
37
37
  checkVideoCache,
38
38
  matchesGlob,
39
39
  savingOf
40
- } from "./chunk-FY53B4NB.js";
40
+ } from "./chunk-KJK7XGWA.js";
41
41
 
42
42
  // src/core/commands/audit.ts
43
43
  import { stat as stat2 } from "fs/promises";
@@ -212,6 +212,8 @@ async function estimateVideo(item, deps, sourcePath) {
212
212
  const starts = sampleStarts(item.durationSec);
213
213
  const exact = starts.length === 1 && starts[0].durationSec >= item.durationSec;
214
214
  const perCodec = [];
215
+ const stepTotal = item.outputs.filter((o) => o.role === "video" && encoderFor(o.codec, caps)).length * starts.length;
216
+ let stepsDone = 0;
215
217
  for (const out of item.outputs) {
216
218
  if (out.role !== "video") continue;
217
219
  const encoder = encoderFor(out.codec, caps);
@@ -230,10 +232,10 @@ async function estimateVideo(item, deps, sourcePath) {
230
232
  ].join("|");
231
233
  let streamBytes = deps.cache.getEstimate(key)?.bytes;
232
234
  if (streamBytes === void 0) {
233
- let total = 0;
235
+ let total2 = 0;
234
236
  let seconds = 0;
235
237
  for (const s of starts) {
236
- total += await deps.engine.sampleBytes(
238
+ total2 += await deps.engine.sampleBytes(
237
239
  {
238
240
  source: sourcePath,
239
241
  encoder,
@@ -247,14 +249,18 @@ async function estimateVideo(item, deps, sourcePath) {
247
249
  },
248
250
  deps.signal
249
251
  );
252
+ deps.onStep?.({ done: ++stepsDone, total: stepTotal });
250
253
  seconds += s.durationSec;
251
254
  }
252
- streamBytes = Math.round(seconds > 0 ? total / seconds * item.durationSec : total);
255
+ streamBytes = Math.round(seconds > 0 ? total2 / seconds * item.durationSec : total2);
253
256
  deps.cache.setEstimate(key, { bytes: streamBytes, basis: exact ? "exact" : "extrapolated" });
254
257
  }
258
+ const total = streamBytes + audioBytes(out, item);
255
259
  perCodec.push({
256
260
  codec: out.codec,
257
- bytes: streamBytes + audioBytes(out, item),
261
+ // A size cap is met by raising the CRF or narrowing the width, or the output is not
262
+ // written at all, so what a run will produce never exceeds it.
263
+ bytes: out.search?.maxBytes !== void 0 ? Math.min(total, out.search.maxBytes) : total,
258
264
  basis: exact ? "exact" : "extrapolated"
259
265
  });
260
266
  }
@@ -444,7 +450,14 @@ async function audit(options) {
444
450
  {
445
451
  engine: options.videoEngine,
446
452
  cache,
447
- ffmpegVersion: videoVersions.ffmpeg ?? "unknown"
453
+ ffmpegVersion: videoVersions.ffmpeg ?? "unknown",
454
+ onStep: (step) => options.onProgress?.({
455
+ phase: "estimate",
456
+ done: probed - 1,
457
+ total,
458
+ source: item.source,
459
+ detail: `encoding sample ${step.done} of ${step.total}`
460
+ })
448
461
  },
449
462
  absolute
450
463
  );
@@ -6,7 +6,7 @@ import {
6
6
  probeFfmpeg,
7
7
  probeFfprobeVersion,
8
8
  resolveBinary
9
- } from "./chunk-3PF6BBFK.js";
9
+ } from "./chunk-PE5NKZCH.js";
10
10
 
11
11
  // src/core/doctor/report.ts
12
12
  var MIN_NODE_MAJOR = 22;