halfsize 0.1.0 → 0.1.2

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 CHANGED
@@ -1,5 +1,5 @@
1
1
  <p align="center">
2
- <img src="docs/card.webp" width="760" alt="halfsize: 23.2 MB to 1.99 MB, 12x smaller, VMAF 84.4">
2
+ <img src="https://raw.githubusercontent.com/YKLovePython/halfsize/main/docs/card.webp" width="760" alt="halfsize: 23.2 MB to 1.99 MB, 12x smaller, VMAF 84.4">
3
3
  </p>
4
4
 
5
5
  <h1 align="center">halfsize</h1>
@@ -12,6 +12,7 @@
12
12
  <a href="https://www.npmjs.com/package/halfsize"><img alt="npm" src="https://img.shields.io/npm/v/halfsize?color=b6f36a"></a>
13
13
  <a href="https://www.npmjs.com/package/halfsize"><img alt="downloads" src="https://img.shields.io/npm/dm/halfsize?color=b6f36a"></a>
14
14
  <a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-b6f36a"></a>
15
+ <a href="https://github.com/YKLovePython/halfsize/actions/workflows/test.yml"><img alt="tests" src="https://github.com/YKLovePython/halfsize/actions/workflows/test.yml/badge.svg"></a>
15
16
  <img alt="node" src="https://img.shields.io/badge/node-%3E%3D18-b6f36a">
16
17
  <img alt="no upload" src="https://img.shields.io/badge/upload-none-b6f36a">
17
18
  </p>
@@ -99,9 +100,6 @@ npx halfsize video.mp4 --to 25mb --dry-run
99
100
  npx halfsize video.mp4 --to 25mb --json
100
101
  ```
101
102
 
102
- > While the npm release is being finalized, the same commands work straight from this repo:
103
- > `npx github:YKLovePython/halfsize video.mp4 --to 25mb`
104
-
105
103
  Install it globally if you will use it more than once:
106
104
 
107
105
  ```bash
@@ -119,7 +117,7 @@ your own limit, and watch the encode. The file goes to a temporary folder **on y
119
117
  machine**; nothing is sent anywhere.
120
118
 
121
119
  <p align="center">
122
- <img src="docs/screenshot.webp" width="860" alt="halfsize local web app showing 23.2 MB compressed to 1.99 MB with VMAF 84.4">
120
+ <img src="https://raw.githubusercontent.com/YKLovePython/halfsize/main/docs/screenshot.webp" width="860" alt="halfsize local web app showing 23.2 MB compressed to 1.99 MB with VMAF 84.4">
123
121
  </p>
124
122
 
125
123
  After each run you can download a **share card** (the image at the top of this README) with the
package/README.zh-CN.md CHANGED
@@ -1,5 +1,5 @@
1
1
  <p align="center">
2
- <img src="docs/card.webp" width="760" alt="halfsize:23.2 MB 压缩到 1.99 MB,小了 12 倍,VMAF 84.4">
2
+ <img src="https://raw.githubusercontent.com/YKLovePython/halfsize/main/docs/card.webp" width="760" alt="halfsize:23.2 MB 压缩到 1.99 MB,小了 12 倍,VMAF 84.4">
3
3
  </p>
4
4
 
5
5
  <h1 align="center">halfsize</h1>
@@ -12,6 +12,7 @@
12
12
  <a href="https://www.npmjs.com/package/halfsize"><img alt="npm" src="https://img.shields.io/npm/v/halfsize?color=b6f36a"></a>
13
13
  <a href="https://www.npmjs.com/package/halfsize"><img alt="downloads" src="https://img.shields.io/npm/dm/halfsize?color=b6f36a"></a>
14
14
  <a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-b6f36a"></a>
15
+ <a href="https://github.com/YKLovePython/halfsize/actions/workflows/test.yml"><img alt="tests" src="https://github.com/YKLovePython/halfsize/actions/workflows/test.yml/badge.svg"></a>
15
16
  <img alt="node" src="https://img.shields.io/badge/node-%3E%3D18-b6f36a">
16
17
  <img alt="no upload" src="https://img.shields.io/badge/%E4%B8%8D%E4%B8%8A%E4%BC%A0-%E6%9C%AC%E5%9C%B0-b6f36a">
17
18
  </p>
@@ -96,9 +97,6 @@ npx halfsize video.mp4 --to 25mb --dry-run
96
97
  npx halfsize video.mp4 --to 25mb --json
97
98
  ```
98
99
 
99
- > npm 版本正在发布中;在那之前,同一条命令可以直接从仓库运行:
100
- > `npx github:YKLovePython/halfsize video.mp4 --to 25mb`
101
-
102
100
  常用的话装到全局:
103
101
 
104
102
  ```bash
@@ -115,7 +113,7 @@ npx halfsize --web
115
113
  然后看着它压。文件只会写进**你自己电脑**的临时目录,不会发到任何地方。
116
114
 
117
115
  <p align="center">
118
- <img src="docs/screenshot.webp" width="860" alt="halfsize 本地网页版:23.2 MB 压缩到 1.99 MB,VMAF 84.4">
116
+ <img src="https://raw.githubusercontent.com/YKLovePython/halfsize/main/docs/screenshot.webp" width="860" alt="halfsize 本地网页版:23.2 MB 压缩到 1.99 MB,VMAF 84.4">
119
117
  </p>
120
118
 
121
119
  每次压完可以生成一张**分享图**(就是本文最上面那张),上面是这次真实的数字。
package/package.json CHANGED
@@ -1,47 +1,47 @@
1
- {
2
- "name": "halfsize",
3
- "version": "0.1.0",
4
- "description": "Compress any video to an exact target size, locally, and prove the quality did not drop.",
5
- "type": "module",
6
- "bin": {
7
- "halfsize": "bin/halfsize.mjs"
8
- },
9
- "repository": {
10
- "type": "git",
11
- "url": "git+https://github.com/YKLovePython/halfsize.git"
12
- },
13
- "homepage": "https://github.com/YKLovePython/halfsize#readme",
14
- "bugs": {
15
- "url": "https://github.com/YKLovePython/halfsize/issues"
16
- },
17
- "engines": {
18
- "node": ">=18"
19
- },
20
- "files": [
21
- "bin",
22
- "src",
23
- "web",
24
- "README.md",
25
- "LICENSE"
26
- ],
27
- "scripts": {
28
- "start": "node bin/halfsize.mjs",
29
- "web": "node bin/halfsize.mjs --web",
30
- "test": "node --test test/human.test.mjs test/plan.test.mjs test/quality.test.mjs test/e2e.test.mjs"
31
- },
32
- "keywords": [
33
- "video",
34
- "compress",
35
- "video-compression",
36
- "ffmpeg",
37
- "target-size",
38
- "vmaf",
39
- "cli",
40
- "windows"
41
- ],
42
- "license": "MIT",
43
- "dependencies": {
44
- "ffmpeg-static": "^5.3.0",
45
- "ffprobe-static": "^3.1.0"
46
- }
47
- }
1
+ {
2
+ "name": "halfsize",
3
+ "version": "0.1.2",
4
+ "description": "Compress any video to an exact target size, locally, and prove the quality did not drop.",
5
+ "type": "module",
6
+ "bin": {
7
+ "halfsize": "bin/halfsize.mjs"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/YKLovePython/halfsize.git"
12
+ },
13
+ "homepage": "https://github.com/YKLovePython/halfsize#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/YKLovePython/halfsize/issues"
16
+ },
17
+ "engines": {
18
+ "node": ">=18"
19
+ },
20
+ "files": [
21
+ "bin",
22
+ "src",
23
+ "web",
24
+ "README.md",
25
+ "LICENSE"
26
+ ],
27
+ "scripts": {
28
+ "start": "node bin/halfsize.mjs",
29
+ "web": "node bin/halfsize.mjs --web",
30
+ "test": "node --test test/human.test.mjs test/plan.test.mjs test/quality.test.mjs test/cli.test.mjs test/e2e.test.mjs"
31
+ },
32
+ "keywords": [
33
+ "video",
34
+ "compress",
35
+ "video-compression",
36
+ "ffmpeg",
37
+ "target-size",
38
+ "vmaf",
39
+ "cli",
40
+ "windows"
41
+ ],
42
+ "license": "MIT",
43
+ "dependencies": {
44
+ "ffmpeg-static": "^5.3.0",
45
+ "ffprobe-static": "^3.1.0"
46
+ }
47
+ }
package/src/cli.mjs CHANGED
@@ -1,298 +1,298 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
- import { spawn } from "node:child_process";
4
- import { probe } from "./probe.mjs";
5
- import { planEncode, estimateBytes } from "./plan.mjs";
6
- import { encode } from "./encode.mjs";
7
- import { measureQuality, verdict } from "./quality.mjs";
8
- import { bytes, duration, bitrate, ratio, parseSize, clock } from "./human.mjs";
9
-
10
- const VERSION = "0.1.0";
11
-
12
- const HELP = `
13
- halfsize ${VERSION} - compress a video to an exact size, and prove it still looks the same.
14
-
15
- Usage
16
- halfsize <file> --to 25mb Compress to a target size
17
- halfsize <file> --to 25mb --codec h265
18
- halfsize <file> --crf 23 Quality-first mode
19
- halfsize --web Open the local app
20
- halfsize --doctor Check FFmpeg and hardware encoders
21
-
22
- Options
23
- --to <size> Target size: 25mb, 700k, 1.5gb
24
- --crf <n> Quality mode (18 best / 23 balanced / 28 small)
25
- --codec <name> h264 (default, compatible) or h265 (smaller)
26
- --preset <name> ultrafast..veryslow (default: medium)
27
- --out <file> Output path
28
- --no-verify Skip the SSIM/VMAF check
29
- --no-downscale Never reduce resolution
30
- --json Machine-readable result
31
- --dry-run Print the plan, do not encode
32
- -h, --help Show this help
33
- -v, --version Show version
34
-
35
- Why
36
- "Just make it smaller" is easy. "Make it fit in 25 MB and still look identical"
37
- is the part everyone gets wrong. halfsize computes the bitrate, picks the
38
- resolution, encodes twice, then measures the result with SSIM and VMAF.
39
- `;
40
-
41
- const DEFAULTS = { codec: "h264", preset: "medium", verify: true, downscale: true };
42
-
43
- function parseArgs(argv) {
44
- const args = { _: [], flags: { ...DEFAULTS } };
45
- for (let i = 0; i < argv.length; i++) {
46
- const a = argv[i];
47
- if (a === "-h" || a === "--help") args.flags.help = true;
48
- else if (a === "-v" || a === "--version") args.flags.version = true;
49
- else if (a === "--json") args.flags.json = true;
50
- else if (a === "--dry-run") args.flags.dryRun = true;
51
- else if (a === "--no-verify") args.flags.verify = false;
52
- else if (a === "--no-downscale") args.flags.downscale = false;
53
- else if (a === "--web") args.flags.web = true;
54
- else if (a === "--doctor") args.flags.doctor = true;
55
- else if (a === "--to") args.flags.to = argv[++i];
56
- else if (a === "--crf") args.flags.crf = Number(argv[++i]);
57
- else if (a === "--codec") args.flags.codec = argv[++i];
58
- else if (a === "--preset") args.flags.preset = argv[++i];
59
- else if (a === "--out") args.flags.out = argv[++i];
60
- else if (a.startsWith("-")) throw new Error(`Unknown option ${a}`);
61
- else args._.push(a);
62
- }
63
- return args;
64
- }
65
-
66
- export async function main(argv) {
67
- const args = parseArgs(argv);
68
- const flags = args.flags;
69
-
70
- if (flags.help || (!args._.length && !flags.web && !flags.doctor)) {
71
- console.log(HELP);
72
- return;
73
- }
74
- if (flags.version) {
75
- console.log(VERSION);
76
- return;
77
- }
78
- if (flags.doctor) return doctor();
79
- if (flags.web) return startWeb();
80
-
81
- const input = path.resolve(args._[0]);
82
- if (!fs.existsSync(input)) throw new Error(`File not found: ${input}`);
83
- if (!flags.to && flags.crf === undefined) {
84
- throw new Error("Pick a target: --to 25mb, or use --crf 23 for quality mode.");
85
- }
86
-
87
- const targetBytes = flags.to ? parseSize(flags.to) : null;
88
- const info = await probe(input);
89
-
90
- if (targetBytes && targetBytes >= info.sizeBytes) {
91
- console.log(`\n This file is already ${bytes(info.sizeBytes)}, smaller than the ${bytes(targetBytes)} target.\n`);
92
- return;
93
- }
94
-
95
- const plan = planEncode(info, {
96
- targetBytes,
97
- crf: flags.crf,
98
- codec: flags.codec,
99
- preset: flags.preset,
100
- downscale: flags.downscale,
101
- });
102
-
103
- const outPath = flags.out
104
- ? path.resolve(flags.out)
105
- : defaultOutput(input, flags, plan);
106
-
107
- if (!flags.json) printPlan(info, plan, flags, outPath);
108
-
109
- if (flags.dryRun) {
110
- if (flags.json) console.log(JSON.stringify({ info, plan, out: outPath }, null, 2));
111
- return;
112
- }
113
-
114
- const progress = new Progress(Boolean(process.stdout.isTTY) && !flags.json, info.duration);
115
- let result;
116
- try {
117
- result = await encode(input, outPath, plan, info, (p) => progress.update(p));
118
- } finally {
119
- progress.finish();
120
- }
121
-
122
- let quality = { ssim: null, vmaf: null };
123
- if (flags.verify && !flags.noVerify) {
124
- if (!flags.json) process.stdout.write(` measuring quality `);
125
- quality = await measureQuality(input, outPath, info.duration);
126
- if (!flags.json) process.stdout.write(`\r${" ".repeat(40)}\r`);
127
- }
128
-
129
- const summary = {
130
- input: { file: input, bytes: info.sizeBytes, ...info.video, duration: info.duration },
131
- output: { file: outPath, bytes: result.outputBytes },
132
- targetBytes,
133
- accuracy: targetBytes ? result.outputBytes / targetBytes - 1 : null,
134
- ratio: info.sizeBytes / result.outputBytes,
135
- saved: info.sizeBytes - result.outputBytes,
136
- plan,
137
- quality,
138
- verdict: verdict(quality),
139
- elapsedMs: result.elapsedMs,
140
- };
141
-
142
- if (flags.json) {
143
- console.log(JSON.stringify(summary, null, 2));
144
- return;
145
- }
146
-
147
- printResult(summary, outPath);
148
- }
149
-
150
- function defaultOutput(input, flags, plan) {
151
- const dir = path.dirname(input);
152
- const ext = path.extname(input);
153
- const base = path.basename(input, ext);
154
- const tag = flags.to ? String(flags.to).replace(/\s+/g, "").toLowerCase() : `crf${flags.crf}`;
155
- return path.join(dir, `${base}.${tag}.mp4`);
156
- }
157
-
158
- function line(label, value) {
159
- console.log(` ${label.padEnd(10)} ${value}`);
160
- }
161
-
162
- function printPlan(info, plan, flags, outPath) {
163
- console.log("");
164
- console.log(` halfsize ${VERSION}`);
165
- console.log("");
166
- console.log(` ${path.basename(info.file)}`);
167
- console.log(
168
- ` ${bytes(info.sizeBytes)} ${duration(info.duration)} ${info.video.width}x${info.video.height} ${info.video.fps.toFixed(2)} fps ${info.video.codec}`,
169
- );
170
- console.log("");
171
- if (plan.mode === "size") line("target", bytes(plan.targetBytes));
172
- else line("mode", `CRF ${plan.crf}`);
173
- line(
174
- "plan",
175
- `${plan.label} ${plan.width}x${plan.height} ${plan.videoKbps ? bitrate(plan.videoKbps) : "auto"} audio ${plan.audioKbps || 0} kbps ${plan.twoPass ? "2-pass" : "1-pass"}`,
176
- );
177
- if (plan.downscaled) line("scale", `${info.video.width}x${info.video.height} -> ${plan.width}x${plan.height}`);
178
- if (plan.mode === "size") {
179
- const est = estimateBytes(info, plan);
180
- line("estimate", `${bytes(est)} (${ratio(info.sizeBytes, est)} smaller)`);
181
- }
182
- line("output", path.basename(outPath));
183
- for (const warning of plan.warnings) console.log(` ! ${warning}`);
184
- console.log("");
185
- }
186
-
187
- class Progress {
188
- constructor(enabled, total) {
189
- this.enabled = enabled;
190
- this.total = total;
191
- this.lastPrint = 0;
192
- this.start = Date.now();
193
- }
194
-
195
- update({ ratio, speed }) {
196
- if (!this.enabled) return;
197
- const now = Date.now();
198
- if (now - this.lastPrint < 120 && ratio < 1) return;
199
- this.lastPrint = now;
200
- const width = 28;
201
- const filled = Math.max(0, Math.min(width, Math.round(ratio * width)));
202
- const bar = "=".repeat(filled) + (filled < width ? ">" : "") + " ".repeat(Math.max(0, width - filled - 1));
203
- const elapsed = (now - this.start) / 1000;
204
- const eta = ratio > 0.02 ? (elapsed / ratio) * (1 - ratio) : 0;
205
- const speedText = String(speed || "").replace("x", "");
206
- process.stdout.write(
207
- `\r [${bar}] ${String(Math.round(ratio * 100)).padStart(3)}% ${speedText ? `${speedText}x` : ""} eta ${clock(eta)} `,
208
- );
209
- }
210
-
211
- finish() {
212
- if (!this.enabled) return;
213
- process.stdout.write(`\r${" ".repeat(72)}\r`);
214
- }
215
- }
216
-
217
- function printResult(summary, outPath) {
218
- const accuracy =
219
- summary.accuracy === null ? "" : `${Math.abs(summary.accuracy * 100).toFixed(1)}% ${summary.accuracy > 0 ? "over" : "under"} target`;
220
- console.log(` done in ${duration(summary.elapsedMs / 1000)}`);
221
- console.log("");
222
- console.log(
223
- ` ${bytes(summary.input.bytes)} -> ${bytes(summary.output.bytes)} ${ratio(summary.input.bytes, summary.output.bytes)} smaller ${accuracy}`,
224
- );
225
- if (summary.quality.vmaf !== null || summary.quality.ssim !== null) {
226
- const parts = [];
227
- if (summary.quality.vmaf !== null) parts.push(`VMAF ${summary.quality.vmaf.toFixed(1)}`);
228
- if (summary.quality.ssim !== null) parts.push(`SSIM ${summary.quality.ssim.toFixed(3)}`);
229
- parts.push(summary.verdict.label);
230
- console.log(` ${parts.join(" ")}`);
231
- }
232
- console.log("");
233
- console.log(` -> ${outPath}`);
234
- console.log("");
235
- }
236
-
237
- async function doctor() {
238
- const { resolveBinaries } = await import("./ffmpeg.mjs");
239
- const { execFile } = await import("node:child_process");
240
- const { promisify } = await import("node:util");
241
- const run = promisify(execFile);
242
-
243
- console.log("");
244
- console.log(` halfsize ${VERSION} doctor`);
245
- console.log("");
246
- try {
247
- const bins = await resolveBinaries();
248
- const { stdout } = await run(bins.ffmpeg, ["-version"]);
249
- console.log(` ffmpeg ${bins.ffmpeg}`);
250
- console.log(` source ${bins.source}`);
251
- console.log(` version ${stdout.split("\n")[0].replace("ffmpeg version ", "")}`);
252
- } catch (err) {
253
- console.log(` ffmpeg missing`);
254
- console.log(` ${err.message.split("\n")[0]}`);
255
- return;
256
- }
257
-
258
- try {
259
- const bins = await resolveBinaries();
260
- const { stdout } = await run(bins.ffmpeg, ["-hide_banner", "-encoders"]);
261
- const encoders = ["libx264", "libx265", "h264_nvenc", "hevc_nvenc", "h264_qsv", "h264_amf"];
262
- console.log("");
263
- for (const name of encoders) {
264
- console.log(` encoder ${name.padEnd(12)} ${stdout.includes(name) ? "yes" : "no"}`);
265
- }
266
- const { stdout: filters } = await run(bins.ffmpeg, ["-hide_banner", "-filters"]);
267
- console.log(` filter ${"libvmaf".padEnd(12)} ${/\blibvmaf\b/.test(filters) ? "yes" : "no"}`);
268
- } catch {
269
- // ignore
270
- }
271
- console.log("");
272
- }
273
-
274
- async function startWeb() {
275
- const { startServer } = await import("./server.mjs");
276
- const url = await startServer();
277
- console.log("");
278
- console.log(` halfsize is running at ${url}`);
279
- console.log(" Press Ctrl+C to stop.");
280
- console.log("");
281
- if (!process.env.HALFSIZE_NO_OPEN) openBrowser(url);
282
- }
283
-
284
- function openBrowser(url) {
285
- const [command, args] =
286
- process.platform === "win32"
287
- ? ["cmd", ["/c", "start", "", url]]
288
- : process.platform === "darwin"
289
- ? ["open", [url]]
290
- : ["xdg-open", [url]];
291
- try {
292
- const child = spawn(command, args, { detached: true, stdio: "ignore", windowsHide: true });
293
- child.on("error", () => {});
294
- child.unref();
295
- } catch {
296
- // The URL is already printed; opening a browser is a convenience.
297
- }
298
- }
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { spawn } from "node:child_process";
4
+ import { probe } from "./probe.mjs";
5
+ import { planEncode, estimateBytes } from "./plan.mjs";
6
+ import { encode } from "./encode.mjs";
7
+ import { measureQuality, verdict } from "./quality.mjs";
8
+ import { bytes, duration, bitrate, ratio, parseSize, clock } from "./human.mjs";
9
+
10
+ const VERSION = "0.1.2";
11
+
12
+ const HELP = `
13
+ halfsize ${VERSION} - compress a video to an exact size, and prove it still looks the same.
14
+
15
+ Usage
16
+ halfsize <file> --to 25mb Compress to a target size
17
+ halfsize <file> --to 25mb --codec h265
18
+ halfsize <file> --crf 23 Quality-first mode
19
+ halfsize --web Open the local app
20
+ halfsize --doctor Check FFmpeg and hardware encoders
21
+
22
+ Options
23
+ --to <size> Target size: 25mb, 700k, 1.5gb
24
+ --crf <n> Quality mode (18 best / 23 balanced / 28 small)
25
+ --codec <name> h264 (default, compatible) or h265 (smaller)
26
+ --preset <name> ultrafast..veryslow (default: medium)
27
+ --out <file> Output path
28
+ --no-verify Skip the SSIM/VMAF check
29
+ --no-downscale Never reduce resolution
30
+ --json Machine-readable result
31
+ --dry-run Print the plan, do not encode
32
+ -h, --help Show this help
33
+ -v, --version Show version
34
+
35
+ Why
36
+ "Just make it smaller" is easy. "Make it fit in 25 MB and still look identical"
37
+ is the part everyone gets wrong. halfsize computes the bitrate, picks the
38
+ resolution, encodes twice, then measures the result with SSIM and VMAF.
39
+ `;
40
+
41
+ const DEFAULTS = { codec: "h264", preset: "medium", verify: true, downscale: true };
42
+
43
+ function parseArgs(argv) {
44
+ const args = { _: [], flags: { ...DEFAULTS } };
45
+ for (let i = 0; i < argv.length; i++) {
46
+ const a = argv[i];
47
+ if (a === "-h" || a === "--help") args.flags.help = true;
48
+ else if (a === "-v" || a === "--version") args.flags.version = true;
49
+ else if (a === "--json") args.flags.json = true;
50
+ else if (a === "--dry-run") args.flags.dryRun = true;
51
+ else if (a === "--no-verify") args.flags.verify = false;
52
+ else if (a === "--no-downscale") args.flags.downscale = false;
53
+ else if (a === "--web") args.flags.web = true;
54
+ else if (a === "--doctor") args.flags.doctor = true;
55
+ else if (a === "--to") args.flags.to = argv[++i];
56
+ else if (a === "--crf") args.flags.crf = Number(argv[++i]);
57
+ else if (a === "--codec") args.flags.codec = argv[++i];
58
+ else if (a === "--preset") args.flags.preset = argv[++i];
59
+ else if (a === "--out") args.flags.out = argv[++i];
60
+ else if (a.startsWith("-")) throw new Error(`Unknown option ${a}`);
61
+ else args._.push(a);
62
+ }
63
+ return args;
64
+ }
65
+
66
+ export async function main(argv) {
67
+ const args = parseArgs(argv);
68
+ const flags = args.flags;
69
+
70
+ if (flags.version) {
71
+ console.log(VERSION);
72
+ return;
73
+ }
74
+ if (flags.help || (!args._.length && !flags.web && !flags.doctor)) {
75
+ console.log(HELP);
76
+ return;
77
+ }
78
+ if (flags.doctor) return doctor();
79
+ if (flags.web) return startWeb();
80
+
81
+ const input = path.resolve(args._[0]);
82
+ if (!fs.existsSync(input)) throw new Error(`File not found: ${input}`);
83
+ if (!flags.to && flags.crf === undefined) {
84
+ throw new Error("Pick a target: --to 25mb, or use --crf 23 for quality mode.");
85
+ }
86
+
87
+ const targetBytes = flags.to ? parseSize(flags.to) : null;
88
+ const info = await probe(input);
89
+
90
+ if (targetBytes && targetBytes >= info.sizeBytes) {
91
+ console.log(`\n This file is already ${bytes(info.sizeBytes)}, smaller than the ${bytes(targetBytes)} target.\n`);
92
+ return;
93
+ }
94
+
95
+ const plan = planEncode(info, {
96
+ targetBytes,
97
+ crf: flags.crf,
98
+ codec: flags.codec,
99
+ preset: flags.preset,
100
+ downscale: flags.downscale,
101
+ });
102
+
103
+ const outPath = flags.out
104
+ ? path.resolve(flags.out)
105
+ : defaultOutput(input, flags, plan);
106
+
107
+ if (!flags.json) printPlan(info, plan, flags, outPath);
108
+
109
+ if (flags.dryRun) {
110
+ if (flags.json) console.log(JSON.stringify({ info, plan, out: outPath }, null, 2));
111
+ return;
112
+ }
113
+
114
+ const progress = new Progress(Boolean(process.stdout.isTTY) && !flags.json, info.duration);
115
+ let result;
116
+ try {
117
+ result = await encode(input, outPath, plan, info, (p) => progress.update(p));
118
+ } finally {
119
+ progress.finish();
120
+ }
121
+
122
+ let quality = { ssim: null, vmaf: null };
123
+ if (flags.verify && !flags.noVerify) {
124
+ if (!flags.json) process.stdout.write(` measuring quality `);
125
+ quality = await measureQuality(input, outPath, info.duration);
126
+ if (!flags.json) process.stdout.write(`\r${" ".repeat(40)}\r`);
127
+ }
128
+
129
+ const summary = {
130
+ input: { file: input, bytes: info.sizeBytes, ...info.video, duration: info.duration },
131
+ output: { file: outPath, bytes: result.outputBytes },
132
+ targetBytes,
133
+ accuracy: targetBytes ? result.outputBytes / targetBytes - 1 : null,
134
+ ratio: info.sizeBytes / result.outputBytes,
135
+ saved: info.sizeBytes - result.outputBytes,
136
+ plan,
137
+ quality,
138
+ verdict: verdict(quality),
139
+ elapsedMs: result.elapsedMs,
140
+ };
141
+
142
+ if (flags.json) {
143
+ console.log(JSON.stringify(summary, null, 2));
144
+ return;
145
+ }
146
+
147
+ printResult(summary, outPath);
148
+ }
149
+
150
+ function defaultOutput(input, flags, plan) {
151
+ const dir = path.dirname(input);
152
+ const ext = path.extname(input);
153
+ const base = path.basename(input, ext);
154
+ const tag = flags.to ? String(flags.to).replace(/\s+/g, "").toLowerCase() : `crf${flags.crf}`;
155
+ return path.join(dir, `${base}.${tag}.mp4`);
156
+ }
157
+
158
+ function line(label, value) {
159
+ console.log(` ${label.padEnd(10)} ${value}`);
160
+ }
161
+
162
+ function printPlan(info, plan, flags, outPath) {
163
+ console.log("");
164
+ console.log(` halfsize ${VERSION}`);
165
+ console.log("");
166
+ console.log(` ${path.basename(info.file)}`);
167
+ console.log(
168
+ ` ${bytes(info.sizeBytes)} ${duration(info.duration)} ${info.video.width}x${info.video.height} ${info.video.fps.toFixed(2)} fps ${info.video.codec}`,
169
+ );
170
+ console.log("");
171
+ if (plan.mode === "size") line("target", bytes(plan.targetBytes));
172
+ else line("mode", `CRF ${plan.crf}`);
173
+ line(
174
+ "plan",
175
+ `${plan.label} ${plan.width}x${plan.height} ${plan.videoKbps ? bitrate(plan.videoKbps) : "auto"} audio ${plan.audioKbps || 0} kbps ${plan.twoPass ? "2-pass" : "1-pass"}`,
176
+ );
177
+ if (plan.downscaled) line("scale", `${info.video.width}x${info.video.height} -> ${plan.width}x${plan.height}`);
178
+ if (plan.mode === "size") {
179
+ const est = estimateBytes(info, plan);
180
+ line("estimate", `${bytes(est)} (${ratio(info.sizeBytes, est)} smaller)`);
181
+ }
182
+ line("output", path.basename(outPath));
183
+ for (const warning of plan.warnings) console.log(` ! ${warning}`);
184
+ console.log("");
185
+ }
186
+
187
+ class Progress {
188
+ constructor(enabled, total) {
189
+ this.enabled = enabled;
190
+ this.total = total;
191
+ this.lastPrint = 0;
192
+ this.start = Date.now();
193
+ }
194
+
195
+ update({ ratio, speed }) {
196
+ if (!this.enabled) return;
197
+ const now = Date.now();
198
+ if (now - this.lastPrint < 120 && ratio < 1) return;
199
+ this.lastPrint = now;
200
+ const width = 28;
201
+ const filled = Math.max(0, Math.min(width, Math.round(ratio * width)));
202
+ const bar = "=".repeat(filled) + (filled < width ? ">" : "") + " ".repeat(Math.max(0, width - filled - 1));
203
+ const elapsed = (now - this.start) / 1000;
204
+ const eta = ratio > 0.02 ? (elapsed / ratio) * (1 - ratio) : 0;
205
+ const speedText = String(speed || "").replace("x", "");
206
+ process.stdout.write(
207
+ `\r [${bar}] ${String(Math.round(ratio * 100)).padStart(3)}% ${speedText ? `${speedText}x` : ""} eta ${clock(eta)} `,
208
+ );
209
+ }
210
+
211
+ finish() {
212
+ if (!this.enabled) return;
213
+ process.stdout.write(`\r${" ".repeat(72)}\r`);
214
+ }
215
+ }
216
+
217
+ function printResult(summary, outPath) {
218
+ const accuracy =
219
+ summary.accuracy === null ? "" : `${Math.abs(summary.accuracy * 100).toFixed(1)}% ${summary.accuracy > 0 ? "over" : "under"} target`;
220
+ console.log(` done in ${duration(summary.elapsedMs / 1000)}`);
221
+ console.log("");
222
+ console.log(
223
+ ` ${bytes(summary.input.bytes)} -> ${bytes(summary.output.bytes)} ${ratio(summary.input.bytes, summary.output.bytes)} smaller ${accuracy}`,
224
+ );
225
+ if (summary.quality.vmaf !== null || summary.quality.ssim !== null) {
226
+ const parts = [];
227
+ if (summary.quality.vmaf !== null) parts.push(`VMAF ${summary.quality.vmaf.toFixed(1)}`);
228
+ if (summary.quality.ssim !== null) parts.push(`SSIM ${summary.quality.ssim.toFixed(3)}`);
229
+ parts.push(summary.verdict.label);
230
+ console.log(` ${parts.join(" ")}`);
231
+ }
232
+ console.log("");
233
+ console.log(` -> ${outPath}`);
234
+ console.log("");
235
+ }
236
+
237
+ async function doctor() {
238
+ const { resolveBinaries } = await import("./ffmpeg.mjs");
239
+ const { execFile } = await import("node:child_process");
240
+ const { promisify } = await import("node:util");
241
+ const run = promisify(execFile);
242
+
243
+ console.log("");
244
+ console.log(` halfsize ${VERSION} doctor`);
245
+ console.log("");
246
+ try {
247
+ const bins = await resolveBinaries();
248
+ const { stdout } = await run(bins.ffmpeg, ["-version"]);
249
+ console.log(` ffmpeg ${bins.ffmpeg}`);
250
+ console.log(` source ${bins.source}`);
251
+ console.log(` version ${stdout.split("\n")[0].replace("ffmpeg version ", "")}`);
252
+ } catch (err) {
253
+ console.log(` ffmpeg missing`);
254
+ console.log(` ${err.message.split("\n")[0]}`);
255
+ return;
256
+ }
257
+
258
+ try {
259
+ const bins = await resolveBinaries();
260
+ const { stdout } = await run(bins.ffmpeg, ["-hide_banner", "-encoders"]);
261
+ const encoders = ["libx264", "libx265", "h264_nvenc", "hevc_nvenc", "h264_qsv", "h264_amf"];
262
+ console.log("");
263
+ for (const name of encoders) {
264
+ console.log(` encoder ${name.padEnd(12)} ${stdout.includes(name) ? "yes" : "no"}`);
265
+ }
266
+ const { stdout: filters } = await run(bins.ffmpeg, ["-hide_banner", "-filters"]);
267
+ console.log(` filter ${"libvmaf".padEnd(12)} ${/\blibvmaf\b/.test(filters) ? "yes" : "no"}`);
268
+ } catch {
269
+ // ignore
270
+ }
271
+ console.log("");
272
+ }
273
+
274
+ async function startWeb() {
275
+ const { startServer } = await import("./server.mjs");
276
+ const url = await startServer();
277
+ console.log("");
278
+ console.log(` halfsize is running at ${url}`);
279
+ console.log(" Press Ctrl+C to stop.");
280
+ console.log("");
281
+ if (!process.env.HALFSIZE_NO_OPEN) openBrowser(url);
282
+ }
283
+
284
+ function openBrowser(url) {
285
+ const [command, args] =
286
+ process.platform === "win32"
287
+ ? ["cmd", ["/c", "start", "", url]]
288
+ : process.platform === "darwin"
289
+ ? ["open", [url]]
290
+ : ["xdg-open", [url]];
291
+ try {
292
+ const child = spawn(command, args, { detached: true, stdio: "ignore", windowsHide: true });
293
+ child.on("error", () => {});
294
+ child.unref();
295
+ } catch {
296
+ // The URL is already printed; opening a browser is a convenience.
297
+ }
298
+ }