halfsize 0.1.1 → 0.1.3
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 +2 -3
- package/README.zh-CN.md +2 -3
- package/package.json +47 -47
- package/src/cli.mjs +302 -298
- package/src/encode.mjs +3 -3
- package/web/index.html +4 -1
package/README.md
CHANGED
|
@@ -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
|
|
@@ -166,6 +164,7 @@ halfsize <file> [options]
|
|
|
166
164
|
--json Machine-readable result
|
|
167
165
|
--dry-run Print the plan, do not encode
|
|
168
166
|
--web Start the local web app
|
|
167
|
+
--port <n> Port for the web app (default 4174)
|
|
169
168
|
--doctor Show which FFmpeg and encoders were found
|
|
170
169
|
-h, --help Help
|
|
171
170
|
-v, --version Version
|
package/README.zh-CN.md
CHANGED
|
@@ -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
|
|
@@ -157,6 +155,7 @@ halfsize <file> [options]
|
|
|
157
155
|
--json 机器可读结果
|
|
158
156
|
--dry-run 只打印方案,不编码
|
|
159
157
|
--web 启动本地网页版
|
|
158
|
+
--port <n> 网页版端口(默认 4174)
|
|
160
159
|
--doctor 查看找到的 FFmpeg 和编码器
|
|
161
160
|
-h, --help 帮助
|
|
162
161
|
-v, --version 版本
|
package/package.json
CHANGED
|
@@ -1,47 +1,47 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "halfsize",
|
|
3
|
-
"version": "0.1.
|
|
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
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "halfsize",
|
|
3
|
+
"version": "0.1.3",
|
|
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,302 @@
|
|
|
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.
|
|
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 --
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
--
|
|
25
|
-
--
|
|
26
|
-
--
|
|
27
|
-
--
|
|
28
|
-
--
|
|
29
|
-
--no-
|
|
30
|
-
--
|
|
31
|
-
--
|
|
32
|
-
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
else if (a === "
|
|
51
|
-
else if (a === "--
|
|
52
|
-
else if (a === "--
|
|
53
|
-
else if (a === "--
|
|
54
|
-
else if (a === "--
|
|
55
|
-
else if (a === "--
|
|
56
|
-
else if (a === "--
|
|
57
|
-
else if (a === "--
|
|
58
|
-
else if (a === "--
|
|
59
|
-
else if (a === "--
|
|
60
|
-
else if (a
|
|
61
|
-
else args.
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
if (
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
console.log(
|
|
167
|
-
console.log(
|
|
168
|
-
|
|
169
|
-
);
|
|
170
|
-
console.log(
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
this.
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
this.
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
const
|
|
204
|
-
const
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
);
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
);
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
console.log(` ffmpeg
|
|
254
|
-
console.log(` ${
|
|
255
|
-
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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.3";
|
|
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 --web --port 8080 Use a different port
|
|
21
|
+
halfsize --doctor Check FFmpeg and hardware encoders
|
|
22
|
+
|
|
23
|
+
Options
|
|
24
|
+
--to <size> Target size: 25mb, 700k, 1.5gb
|
|
25
|
+
--crf <n> Quality mode (18 best / 23 balanced / 28 small)
|
|
26
|
+
--codec <name> h264 (default, compatible) or h265 (smaller)
|
|
27
|
+
--preset <name> ultrafast..veryslow (default: medium)
|
|
28
|
+
--out <file> Output path
|
|
29
|
+
--no-verify Skip the SSIM/VMAF check
|
|
30
|
+
--no-downscale Never reduce resolution
|
|
31
|
+
--json Machine-readable result
|
|
32
|
+
--dry-run Print the plan, do not encode
|
|
33
|
+
--port <n> Port for --web (default 4174)
|
|
34
|
+
-h, --help Show this help
|
|
35
|
+
-v, --version Show version
|
|
36
|
+
|
|
37
|
+
Why
|
|
38
|
+
"Just make it smaller" is easy. "Make it fit in 25 MB and still look identical"
|
|
39
|
+
is the part everyone gets wrong. halfsize computes the bitrate, picks the
|
|
40
|
+
resolution, encodes twice, then measures the result with SSIM and VMAF.
|
|
41
|
+
`;
|
|
42
|
+
|
|
43
|
+
const DEFAULTS = { codec: "h264", preset: "medium", verify: true, downscale: true };
|
|
44
|
+
|
|
45
|
+
function parseArgs(argv) {
|
|
46
|
+
const args = { _: [], flags: { ...DEFAULTS } };
|
|
47
|
+
for (let i = 0; i < argv.length; i++) {
|
|
48
|
+
const a = argv[i];
|
|
49
|
+
if (a === "-h" || a === "--help") args.flags.help = true;
|
|
50
|
+
else if (a === "-v" || a === "--version") args.flags.version = true;
|
|
51
|
+
else if (a === "--json") args.flags.json = true;
|
|
52
|
+
else if (a === "--dry-run") args.flags.dryRun = true;
|
|
53
|
+
else if (a === "--no-verify") args.flags.verify = false;
|
|
54
|
+
else if (a === "--no-downscale") args.flags.downscale = false;
|
|
55
|
+
else if (a === "--web") args.flags.web = true;
|
|
56
|
+
else if (a === "--doctor") args.flags.doctor = true;
|
|
57
|
+
else if (a === "--port") args.flags.port = Number(argv[++i]);
|
|
58
|
+
else if (a === "--to") args.flags.to = argv[++i];
|
|
59
|
+
else if (a === "--crf") args.flags.crf = Number(argv[++i]);
|
|
60
|
+
else if (a === "--codec") args.flags.codec = argv[++i];
|
|
61
|
+
else if (a === "--preset") args.flags.preset = argv[++i];
|
|
62
|
+
else if (a === "--out") args.flags.out = argv[++i];
|
|
63
|
+
else if (a.startsWith("-")) throw new Error(`Unknown option ${a}`);
|
|
64
|
+
else args._.push(a);
|
|
65
|
+
}
|
|
66
|
+
return args;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function main(argv) {
|
|
70
|
+
const args = parseArgs(argv);
|
|
71
|
+
const flags = args.flags;
|
|
72
|
+
|
|
73
|
+
if (flags.version) {
|
|
74
|
+
console.log(VERSION);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (flags.help || (!args._.length && !flags.web && !flags.doctor)) {
|
|
78
|
+
console.log(HELP);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (flags.doctor) return doctor();
|
|
82
|
+
if (flags.web) return startWeb(flags.port);
|
|
83
|
+
|
|
84
|
+
const input = path.resolve(args._[0]);
|
|
85
|
+
if (!fs.existsSync(input)) throw new Error(`File not found: ${input}`);
|
|
86
|
+
if (!flags.to && flags.crf === undefined) {
|
|
87
|
+
throw new Error("Pick a target: --to 25mb, or use --crf 23 for quality mode.");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const targetBytes = flags.to ? parseSize(flags.to) : null;
|
|
91
|
+
const info = await probe(input);
|
|
92
|
+
|
|
93
|
+
if (targetBytes && targetBytes >= info.sizeBytes) {
|
|
94
|
+
console.log(`\n This file is already ${bytes(info.sizeBytes)}, smaller than the ${bytes(targetBytes)} target.\n`);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const plan = planEncode(info, {
|
|
99
|
+
targetBytes,
|
|
100
|
+
crf: flags.crf,
|
|
101
|
+
codec: flags.codec,
|
|
102
|
+
preset: flags.preset,
|
|
103
|
+
downscale: flags.downscale,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const outPath = flags.out
|
|
107
|
+
? path.resolve(flags.out)
|
|
108
|
+
: defaultOutput(input, flags, plan);
|
|
109
|
+
|
|
110
|
+
if (!flags.json) printPlan(info, plan, flags, outPath);
|
|
111
|
+
|
|
112
|
+
if (flags.dryRun) {
|
|
113
|
+
if (flags.json) console.log(JSON.stringify({ info, plan, out: outPath }, null, 2));
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const progress = new Progress(Boolean(process.stdout.isTTY) && !flags.json, info.duration);
|
|
118
|
+
let result;
|
|
119
|
+
try {
|
|
120
|
+
result = await encode(input, outPath, plan, info, (p) => progress.update(p));
|
|
121
|
+
} finally {
|
|
122
|
+
progress.finish();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let quality = { ssim: null, vmaf: null };
|
|
126
|
+
if (flags.verify && !flags.noVerify) {
|
|
127
|
+
if (!flags.json) process.stdout.write(` measuring quality `);
|
|
128
|
+
quality = await measureQuality(input, outPath, info.duration);
|
|
129
|
+
if (!flags.json) process.stdout.write(`\r${" ".repeat(40)}\r`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const summary = {
|
|
133
|
+
input: { file: input, bytes: info.sizeBytes, ...info.video, duration: info.duration },
|
|
134
|
+
output: { file: outPath, bytes: result.outputBytes },
|
|
135
|
+
targetBytes,
|
|
136
|
+
accuracy: targetBytes ? result.outputBytes / targetBytes - 1 : null,
|
|
137
|
+
ratio: info.sizeBytes / result.outputBytes,
|
|
138
|
+
saved: info.sizeBytes - result.outputBytes,
|
|
139
|
+
plan,
|
|
140
|
+
quality,
|
|
141
|
+
verdict: verdict(quality),
|
|
142
|
+
elapsedMs: result.elapsedMs,
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
if (flags.json) {
|
|
146
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
printResult(summary, outPath);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function defaultOutput(input, flags, plan) {
|
|
154
|
+
const dir = path.dirname(input);
|
|
155
|
+
const ext = path.extname(input);
|
|
156
|
+
const base = path.basename(input, ext);
|
|
157
|
+
const tag = flags.to ? String(flags.to).replace(/\s+/g, "").toLowerCase() : `crf${flags.crf}`;
|
|
158
|
+
return path.join(dir, `${base}.${tag}.mp4`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function line(label, value) {
|
|
162
|
+
console.log(` ${label.padEnd(10)} ${value}`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function printPlan(info, plan, flags, outPath) {
|
|
166
|
+
console.log("");
|
|
167
|
+
console.log(` halfsize ${VERSION}`);
|
|
168
|
+
console.log("");
|
|
169
|
+
console.log(` ${path.basename(info.file)}`);
|
|
170
|
+
console.log(
|
|
171
|
+
` ${bytes(info.sizeBytes)} ${duration(info.duration)} ${info.video.width}x${info.video.height} ${info.video.fps.toFixed(2)} fps ${info.video.codec}`,
|
|
172
|
+
);
|
|
173
|
+
console.log("");
|
|
174
|
+
if (plan.mode === "size") line("target", bytes(plan.targetBytes));
|
|
175
|
+
else line("mode", `CRF ${plan.crf}`);
|
|
176
|
+
line(
|
|
177
|
+
"plan",
|
|
178
|
+
`${plan.label} ${plan.width}x${plan.height} ${plan.videoKbps ? bitrate(plan.videoKbps) : "auto"} audio ${plan.audioKbps || 0} kbps ${plan.twoPass ? "2-pass" : "1-pass"}`,
|
|
179
|
+
);
|
|
180
|
+
if (plan.downscaled) line("scale", `${info.video.width}x${info.video.height} -> ${plan.width}x${plan.height}`);
|
|
181
|
+
if (plan.mode === "size") {
|
|
182
|
+
const est = estimateBytes(info, plan);
|
|
183
|
+
line("estimate", `${bytes(est)} (${ratio(info.sizeBytes, est)} smaller)`);
|
|
184
|
+
}
|
|
185
|
+
line("output", path.basename(outPath));
|
|
186
|
+
for (const warning of plan.warnings) console.log(` ! ${warning}`);
|
|
187
|
+
console.log("");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
class Progress {
|
|
191
|
+
constructor(enabled, total) {
|
|
192
|
+
this.enabled = enabled;
|
|
193
|
+
this.total = total;
|
|
194
|
+
this.lastPrint = 0;
|
|
195
|
+
this.start = Date.now();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
update({ ratio, speed, pass, passes }) {
|
|
199
|
+
if (!this.enabled) return;
|
|
200
|
+
const now = Date.now();
|
|
201
|
+
if (now - this.lastPrint < 120 && ratio < 1) return;
|
|
202
|
+
this.lastPrint = now;
|
|
203
|
+
const width = 28;
|
|
204
|
+
const filled = Math.max(0, Math.min(width, Math.round(ratio * width)));
|
|
205
|
+
const bar = "=".repeat(filled) + (filled < width ? ">" : "") + " ".repeat(Math.max(0, width - filled - 1));
|
|
206
|
+
const elapsed = (now - this.start) / 1000;
|
|
207
|
+
const eta = ratio > 0.02 ? (elapsed / ratio) * (1 - ratio) : 0;
|
|
208
|
+
const speedText = String(speed || "").replace("x", "");
|
|
209
|
+
const passText = passes > 1 ? `pass ${pass}/2 ` : "";
|
|
210
|
+
process.stdout.write(
|
|
211
|
+
`\r [${bar}] ${String(Math.round(ratio * 100)).padStart(3)}% ${passText}${speedText ? `${speedText}x` : ""} eta ${clock(eta)} `,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
finish() {
|
|
216
|
+
if (!this.enabled) return;
|
|
217
|
+
process.stdout.write(`\r${" ".repeat(72)}\r`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function printResult(summary, outPath) {
|
|
222
|
+
const accuracy =
|
|
223
|
+
summary.accuracy === null ? "" : `${Math.abs(summary.accuracy * 100).toFixed(1)}% ${summary.accuracy > 0 ? "over" : "under"} target`;
|
|
224
|
+
console.log(` done in ${duration(summary.elapsedMs / 1000)}`);
|
|
225
|
+
console.log("");
|
|
226
|
+
console.log(
|
|
227
|
+
` ${bytes(summary.input.bytes)} -> ${bytes(summary.output.bytes)} ${ratio(summary.input.bytes, summary.output.bytes)} smaller ${accuracy}`,
|
|
228
|
+
);
|
|
229
|
+
if (summary.quality.vmaf !== null || summary.quality.ssim !== null) {
|
|
230
|
+
const parts = [];
|
|
231
|
+
if (summary.quality.vmaf !== null) parts.push(`VMAF ${summary.quality.vmaf.toFixed(1)}`);
|
|
232
|
+
if (summary.quality.ssim !== null) parts.push(`SSIM ${summary.quality.ssim.toFixed(3)}`);
|
|
233
|
+
parts.push(summary.verdict.label);
|
|
234
|
+
console.log(` ${parts.join(" ")}`);
|
|
235
|
+
}
|
|
236
|
+
console.log("");
|
|
237
|
+
console.log(` -> ${outPath}`);
|
|
238
|
+
console.log("");
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function doctor() {
|
|
242
|
+
const { resolveBinaries } = await import("./ffmpeg.mjs");
|
|
243
|
+
const { execFile } = await import("node:child_process");
|
|
244
|
+
const { promisify } = await import("node:util");
|
|
245
|
+
const run = promisify(execFile);
|
|
246
|
+
|
|
247
|
+
console.log("");
|
|
248
|
+
console.log(` halfsize ${VERSION} doctor`);
|
|
249
|
+
console.log("");
|
|
250
|
+
try {
|
|
251
|
+
const bins = await resolveBinaries();
|
|
252
|
+
const { stdout } = await run(bins.ffmpeg, ["-version"]);
|
|
253
|
+
console.log(` ffmpeg ${bins.ffmpeg}`);
|
|
254
|
+
console.log(` source ${bins.source}`);
|
|
255
|
+
console.log(` version ${stdout.split("\n")[0].replace("ffmpeg version ", "")}`);
|
|
256
|
+
} catch (err) {
|
|
257
|
+
console.log(` ffmpeg missing`);
|
|
258
|
+
console.log(` ${err.message.split("\n")[0]}`);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
try {
|
|
263
|
+
const bins = await resolveBinaries();
|
|
264
|
+
const { stdout } = await run(bins.ffmpeg, ["-hide_banner", "-encoders"]);
|
|
265
|
+
const encoders = ["libx264", "libx265", "h264_nvenc", "hevc_nvenc", "h264_qsv", "h264_amf"];
|
|
266
|
+
console.log("");
|
|
267
|
+
for (const name of encoders) {
|
|
268
|
+
console.log(` encoder ${name.padEnd(12)} ${stdout.includes(name) ? "yes" : "no"}`);
|
|
269
|
+
}
|
|
270
|
+
const { stdout: filters } = await run(bins.ffmpeg, ["-hide_banner", "-filters"]);
|
|
271
|
+
console.log(` filter ${"libvmaf".padEnd(12)} ${/\blibvmaf\b/.test(filters) ? "yes" : "no"}`);
|
|
272
|
+
} catch {
|
|
273
|
+
// ignore
|
|
274
|
+
}
|
|
275
|
+
console.log("");
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function startWeb(port) {
|
|
279
|
+
const { startServer } = await import("./server.mjs");
|
|
280
|
+
const url = await startServer(port ? { port } : {});
|
|
281
|
+
console.log("");
|
|
282
|
+
console.log(` halfsize is running at ${url}`);
|
|
283
|
+
console.log(" Press Ctrl+C to stop.");
|
|
284
|
+
console.log("");
|
|
285
|
+
if (!process.env.HALFSIZE_NO_OPEN) openBrowser(url);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function openBrowser(url) {
|
|
289
|
+
const [command, args] =
|
|
290
|
+
process.platform === "win32"
|
|
291
|
+
? ["cmd", ["/c", "start", "", url]]
|
|
292
|
+
: process.platform === "darwin"
|
|
293
|
+
? ["open", [url]]
|
|
294
|
+
: ["xdg-open", [url]];
|
|
295
|
+
try {
|
|
296
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore", windowsHide: true });
|
|
297
|
+
child.on("error", () => {});
|
|
298
|
+
child.unref();
|
|
299
|
+
} catch {
|
|
300
|
+
// The URL is already printed; opening a browser is a convenience.
|
|
301
|
+
}
|
|
302
|
+
}
|
package/src/encode.mjs
CHANGED
|
@@ -46,7 +46,7 @@ export async function encode(input, output, plan, info, onProgress = () => {}) {
|
|
|
46
46
|
"-f", "null",
|
|
47
47
|
NULL_DEVICE,
|
|
48
48
|
];
|
|
49
|
-
await runFfmpeg(ffmpeg, pass1, info.duration, () => {});
|
|
49
|
+
await runFfmpeg(ffmpeg, pass1, info.duration, (p) => onProgress({ ...p, ratio: p.ratio * 0.5, pass: 1, passes: 2 }));
|
|
50
50
|
const pass2 = [
|
|
51
51
|
...baseArgs(input, plan, passlog),
|
|
52
52
|
"-b:v", `${plan.videoKbps}k`,
|
|
@@ -55,7 +55,7 @@ export async function encode(input, output, plan, info, onProgress = () => {}) {
|
|
|
55
55
|
"-movflags", "+faststart",
|
|
56
56
|
output,
|
|
57
57
|
];
|
|
58
|
-
await runFfmpeg(ffmpeg, pass2, info.duration, onProgress);
|
|
58
|
+
await runFfmpeg(ffmpeg, pass2, info.duration, (p) => onProgress({ ...p, ratio: 0.5 + p.ratio * 0.5, pass: 2, passes: 2 }));
|
|
59
59
|
} else {
|
|
60
60
|
const args = [...baseArgs(input, plan, null)];
|
|
61
61
|
if (plan.mode === "size") {
|
|
@@ -64,7 +64,7 @@ export async function encode(input, output, plan, info, onProgress = () => {}) {
|
|
|
64
64
|
args.push("-crf", String(plan.crf));
|
|
65
65
|
}
|
|
66
66
|
args.push(...audioArgs(plan), "-movflags", "+faststart", output);
|
|
67
|
-
await runFfmpeg(ffmpeg, args, info.duration, onProgress);
|
|
67
|
+
await runFfmpeg(ffmpeg, args, info.duration, (p) => onProgress({ ...p, pass: 1, passes: 1 }));
|
|
68
68
|
}
|
|
69
69
|
} finally {
|
|
70
70
|
cleanup(passlog);
|
package/web/index.html
CHANGED
|
@@ -248,6 +248,7 @@ const I18N = {
|
|
|
248
248
|
smallerLabel: "更小",
|
|
249
249
|
accuracyUnder: (p) => `比目标小 ${p}%`,
|
|
250
250
|
accuracyOver: (p) => `超出目标 ${p}%`,
|
|
251
|
+
passLabel: (n) => `第 ${n}/2 遍`,
|
|
251
252
|
alreadySmall: (a, b) => `这个文件已经只有 ${a},小于 ${b} 的目标。换一个更小的目标。`,
|
|
252
253
|
badSize: "看不懂这个大小。试试 25mb、700k 或 1.5gb。",
|
|
253
254
|
failed: "压缩失败",
|
|
@@ -287,6 +288,7 @@ const I18N = {
|
|
|
287
288
|
smallerLabel: "smaller",
|
|
288
289
|
accuracyUnder: (p) => `${p}% under target`,
|
|
289
290
|
accuracyOver: (p) => `${p}% over target`,
|
|
291
|
+
passLabel: (n) => `pass ${n}/2`,
|
|
290
292
|
alreadySmall: (a, b) => `This file is already ${a}, smaller than the ${b} target. Pick a smaller target.`,
|
|
291
293
|
badSize: "Can't read that size. Try 25mb, 700k or 1.5gb.",
|
|
292
294
|
failed: "compression failed",
|
|
@@ -461,7 +463,8 @@ go.addEventListener("click", async () => {
|
|
|
461
463
|
const percent = Math.round((p.ratio || 0) * 100);
|
|
462
464
|
fill.style.width = `${percent}%`;
|
|
463
465
|
pct.textContent = `${percent}%`;
|
|
464
|
-
|
|
466
|
+
const pass = p.passes > 1 ? `${strings.passLabel(p.pass)} ` : "";
|
|
467
|
+
speed.textContent = `${pass}${p.speed || ""} eta ${fmtDur(p.eta)}`;
|
|
465
468
|
});
|
|
466
469
|
es.addEventListener("phase", () => {
|
|
467
470
|
fill.style.width = "100%";
|