halfsize 0.1.2 → 0.1.4
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/CHANGELOG.md +33 -0
- package/README.md +28 -5
- package/README.zh-CN.md +27 -5
- package/package.json +3 -1
- package/src/cli.mjs +115 -31
- package/src/encode.mjs +8 -4
- package/src/plan.mjs +6 -2
- package/web/index.html +4 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.4
|
|
4
|
+
|
|
5
|
+
- Batch mode: `halfsize a.mp4 b.mp4 --to 10mb` compresses several files in a row
|
|
6
|
+
- `--for discord|whatsapp|wechat|email|twitter` picks the platform's upload cap for you
|
|
7
|
+
- `--no-audio` drops the audio track and spends the bits on video instead
|
|
8
|
+
- Streams are mapped explicitly, so cover art and extra tracks cannot be picked by mistake
|
|
9
|
+
- Mono audio stays mono instead of being silently upmixed to stereo
|
|
10
|
+
|
|
11
|
+
## 0.1.3
|
|
12
|
+
|
|
13
|
+
- Two-pass progress now spans both passes and shows `pass 1/2`
|
|
14
|
+
- `--web --port <n>` to pick a port
|
|
15
|
+
- Pass logs are cleaned up even when an encode fails
|
|
16
|
+
|
|
17
|
+
## 0.1.2
|
|
18
|
+
|
|
19
|
+
- npm release is live
|
|
20
|
+
- CI badge in the README
|
|
21
|
+
|
|
22
|
+
## 0.1.1
|
|
23
|
+
|
|
24
|
+
- `--version` no longer falls through to the help screen
|
|
25
|
+
- Force CFR so two-pass encodes of Matroska/VFR sources stay on target
|
|
26
|
+
- Always emit MP4 from the CLI, even for MKV/AVI sources
|
|
27
|
+
|
|
28
|
+
## 0.1.0
|
|
29
|
+
|
|
30
|
+
- First release: exact target size, resolution ladder, two-pass H.264/H.265
|
|
31
|
+
- SSIM sampling and VMAF measurement with a plain-language verdict
|
|
32
|
+
- Local web app with drag and drop, platform presets, and a share card
|
|
33
|
+
- `--json`, `--dry-run`, `--doctor`, and `--no-verify`
|
package/README.md
CHANGED
|
@@ -40,7 +40,7 @@ It computes the bitrate, picks the resolution that actually fits, encodes twice,
|
|
|
40
40
|
the result with **SSIM** and **VMAF** so you know whether it still looks the same.
|
|
41
41
|
|
|
42
42
|
```
|
|
43
|
-
halfsize 0.1.
|
|
43
|
+
halfsize 0.1.4
|
|
44
44
|
|
|
45
45
|
source.mp4
|
|
46
46
|
23.2 MB 8s 1920x1080 30.00 fps h264
|
|
@@ -87,6 +87,12 @@ be downloaded, `halfsize` uses your system install instead.
|
|
|
87
87
|
# compress to a size
|
|
88
88
|
npx halfsize video.mp4 --to 25mb
|
|
89
89
|
|
|
90
|
+
# or just name the app you are uploading to
|
|
91
|
+
npx halfsize video.mp4 --for discord
|
|
92
|
+
|
|
93
|
+
# several files in one go
|
|
94
|
+
npx halfsize clip1.mp4 clip2.mp4 clip3.mp4 --to 10mb
|
|
95
|
+
|
|
90
96
|
# smaller file, same quality budget
|
|
91
97
|
npx halfsize video.mp4 --to 10mb --codec h265
|
|
92
98
|
|
|
@@ -123,6 +129,13 @@ machine**; nothing is sent anywhere.
|
|
|
123
129
|
After each run you can download a **share card** (the image at the top of this README) with the
|
|
124
130
|
real numbers on it.
|
|
125
131
|
|
|
132
|
+
### Use it from an agent
|
|
133
|
+
|
|
134
|
+
The repo ships a skill in [`skills/halfsize/SKILL.md`](skills/halfsize/SKILL.md). Drop that
|
|
135
|
+
folder into your agent's skills directory (for Claude Code: `.claude/skills/halfsize/`) and the
|
|
136
|
+
agent can turn "make this fit in 25 MB" into a compression plus a measured quality report,
|
|
137
|
+
instead of guessing a bitrate.
|
|
138
|
+
|
|
126
139
|
## How it works
|
|
127
140
|
|
|
128
141
|
1. **Probe.** Read duration, resolution, frame rate, audio bitrate, rotation, and HDR metadata.
|
|
@@ -155,15 +168,18 @@ or bitrate. It is not magic, but it is a much better answer than "trust me".
|
|
|
155
168
|
halfsize <file> [options]
|
|
156
169
|
|
|
157
170
|
--to <size> Target size: 25mb, 700k, 1.5gb
|
|
171
|
+
--for <app> Target cap for discord, whatsapp, wechat, email, twitter
|
|
158
172
|
--crf <n> Quality mode instead of size mode (18 best, 23 balanced, 28 small)
|
|
159
173
|
--codec <name> h264 (default, maximum compatibility) or h265 (about 30% smaller)
|
|
160
174
|
--preset <name> ultrafast .. veryslow (default: medium)
|
|
161
175
|
--out <file> Output path
|
|
176
|
+
--no-audio Drop the audio track
|
|
162
177
|
--no-verify Skip SSIM/VMAF
|
|
163
178
|
--no-downscale Never reduce resolution (the target may then be missed)
|
|
164
179
|
--json Machine-readable result
|
|
165
180
|
--dry-run Print the plan, do not encode
|
|
166
181
|
--web Start the local web app
|
|
182
|
+
--port <n> Port for the web app (default 4174)
|
|
167
183
|
--doctor Show which FFmpeg and encoders were found
|
|
168
184
|
-h, --help Help
|
|
169
185
|
-v, --version Version
|
|
@@ -171,6 +187,9 @@ halfsize <file> [options]
|
|
|
171
187
|
|
|
172
188
|
Sizes accept `b`, `k`/`kb`, `m`/`mb`, `g`/`gb`, with or without a space. No unit means MB.
|
|
173
189
|
|
|
190
|
+
`--for` uses the current free-tier caps: `discord` 10 MB, `whatsapp` 16 MB, `wechat` 25 MB,
|
|
191
|
+
`email` 20 MB, `twitter` 512 MB. Caps change, so `--to` always overrides.
|
|
192
|
+
|
|
174
193
|
Exit codes: `0` success, `1` error. `--json` returns the plan, the measured quality, and the
|
|
175
194
|
target accuracy, which makes it easy to wrap in a script.
|
|
176
195
|
|
|
@@ -210,12 +229,15 @@ at the same quality but can be awkward in some editors and older hardware. Defau
|
|
|
210
229
|
Yes. It is plain Node.js and FFmpeg.
|
|
211
230
|
|
|
212
231
|
**Can I batch a folder?**
|
|
213
|
-
|
|
232
|
+
Yes. List the files, or let your shell expand them:
|
|
214
233
|
|
|
215
234
|
```bash
|
|
216
|
-
|
|
235
|
+
npx halfsize *.mp4 --to 25mb
|
|
217
236
|
```
|
|
218
237
|
|
|
238
|
+
Each file gets its own plan and verdict, a failure does not stop the rest, and `--json` returns
|
|
239
|
+
`{ "files": [ ... ] }`. `--out` is allowed only when there is a single input.
|
|
240
|
+
|
|
219
241
|
**Why "halfsize"?**
|
|
220
242
|
Because the honest answer to "how much smaller will it get?" is usually "about half, or a lot
|
|
221
243
|
more than half". The tool reports the real number either way.
|
|
@@ -223,12 +245,11 @@ more than half". The tool reports the real number either way.
|
|
|
223
245
|
## Roadmap
|
|
224
246
|
|
|
225
247
|
- Hardware encoding (NVENC / QSV / VideoToolbox) as an opt-in fast path
|
|
226
|
-
-
|
|
248
|
+
- A folder watcher that compresses new files as they appear
|
|
227
249
|
- Subtitle and multiple audio track passthrough
|
|
228
250
|
- HDR-to-SDR tonemapping, so iPhone HDR clips keep their colors
|
|
229
251
|
- AV1 (`libsvtav1`) when the source is already AV1-friendly
|
|
230
252
|
- A single-file binary, so Node.js is not required
|
|
231
|
-
- Presets for specific platform caps (WeChat, Discord, email, X, Bluesky)
|
|
232
253
|
|
|
233
254
|
If you want one of these, open an issue and say which. Pull requests are welcome; see
|
|
234
255
|
[CONTRIBUTING.md](CONTRIBUTING.md).
|
|
@@ -250,6 +271,8 @@ it to a target, probes the output, and asserts the size and SSIM. No fixtures in
|
|
|
250
271
|
|
|
251
272
|
MIT. Use it, fork it, ship it.
|
|
252
273
|
|
|
274
|
+
See [CHANGELOG.md](CHANGELOG.md) for what changed.
|
|
275
|
+
|
|
253
276
|
---
|
|
254
277
|
|
|
255
278
|
If `halfsize` saved you from guessing a bitrate, a star helps the next person find it.
|
package/README.zh-CN.md
CHANGED
|
@@ -39,7 +39,7 @@ npx halfsize 婚礼.mp4 --to 25mb
|
|
|
39
39
|
画质到底还在不在。
|
|
40
40
|
|
|
41
41
|
```
|
|
42
|
-
halfsize 0.1.
|
|
42
|
+
halfsize 0.1.4
|
|
43
43
|
|
|
44
44
|
source.mp4
|
|
45
45
|
23.2 MB 8s 1920x1080 30.00 fps h264
|
|
@@ -84,6 +84,12 @@ npx halfsize 婚礼.mp4 --to 25mb
|
|
|
84
84
|
# 压到指定大小
|
|
85
85
|
npx halfsize video.mp4 --to 25mb
|
|
86
86
|
|
|
87
|
+
# 或者直接说你要传到哪里
|
|
88
|
+
npx halfsize video.mp4 --for discord
|
|
89
|
+
|
|
90
|
+
# 一次压好几个
|
|
91
|
+
npx halfsize clip1.mp4 clip2.mp4 clip3.mp4 --to 10mb
|
|
92
|
+
|
|
87
93
|
# 更小的文件,H.265
|
|
88
94
|
npx halfsize video.mp4 --to 10mb --codec h265
|
|
89
95
|
|
|
@@ -118,6 +124,12 @@ npx halfsize --web
|
|
|
118
124
|
|
|
119
125
|
每次压完可以生成一张**分享图**(就是本文最上面那张),上面是这次真实的数字。
|
|
120
126
|
|
|
127
|
+
### 给 agent 用
|
|
128
|
+
|
|
129
|
+
仓库里带了 [`skills/halfsize/SKILL.md`](skills/halfsize/SKILL.md)。把整个文件夹放进你的
|
|
130
|
+
agent 技能目录(Claude Code 是 `.claude/skills/halfsize/`),agent 就能把「把这个视频压到
|
|
131
|
+
25 MB」变成一次压缩加一份画质报告,而不是瞎猜码率。
|
|
132
|
+
|
|
121
133
|
## 它怎么工作
|
|
122
134
|
|
|
123
135
|
1. **探测。** 读取时长、分辨率、帧率、音频码率、旋转信息、HDR 标记。
|
|
@@ -146,15 +158,18 @@ VMAF 是 Netflix 提出的感知质量指标,比 PSNR 或「码率多少」更
|
|
|
146
158
|
halfsize <file> [options]
|
|
147
159
|
|
|
148
160
|
--to <size> 目标大小:25mb、700k、1.5gb
|
|
161
|
+
--for <app> 按平台上限:discord、whatsapp、wechat、email、twitter
|
|
149
162
|
--crf <n> 质量模式,不按大小(18 最好,23 均衡,28 更小)
|
|
150
163
|
--codec <name> h264(默认,兼容性最好)或 h265(大约再小 30%)
|
|
151
164
|
--preset <name> ultrafast .. veryslow(默认 medium)
|
|
152
165
|
--out <file> 输出路径
|
|
166
|
+
--no-audio 去掉音轨
|
|
153
167
|
--no-verify 跳过 SSIM/VMAF 检测
|
|
154
168
|
--no-downscale 绝不降分辨率(可能会压不到目标)
|
|
155
169
|
--json 机器可读结果
|
|
156
170
|
--dry-run 只打印方案,不编码
|
|
157
171
|
--web 启动本地网页版
|
|
172
|
+
--port <n> 网页版端口(默认 4174)
|
|
158
173
|
--doctor 查看找到的 FFmpeg 和编码器
|
|
159
174
|
-h, --help 帮助
|
|
160
175
|
-v, --version 版本
|
|
@@ -162,6 +177,9 @@ halfsize <file> [options]
|
|
|
162
177
|
|
|
163
178
|
大小支持 `b`、`k`/`kb`、`m`/`mb`、`g`/`gb`,中间可以有空格。不写单位按 MB 算。
|
|
164
179
|
|
|
180
|
+
`--for` 用的是当前免费档上限:`discord` 10 MB、`whatsapp` 16 MB、`wechat` 25 MB、
|
|
181
|
+
`email` 20 MB、`twitter` 512 MB。平台上限会变,写死不如随时用 `--to` 覆盖。
|
|
182
|
+
|
|
165
183
|
退出码:`0` 成功,`1` 出错。`--json` 会返回方案、画质测量和目标误差,方便包进脚本。
|
|
166
184
|
|
|
167
185
|
## 安装说明
|
|
@@ -197,24 +215,26 @@ H.264 到处都能播,包括老安卓机和浏览器。H.265 同画质大约
|
|
|
197
215
|
能。就是 Node.js + FFmpeg,没有平台特定的东西。
|
|
198
216
|
|
|
199
217
|
**能批量压一个文件夹吗?**
|
|
200
|
-
|
|
218
|
+
能。直接把文件列出来,或者让 shell 展开:
|
|
201
219
|
|
|
202
220
|
```bash
|
|
203
|
-
|
|
221
|
+
npx halfsize *.mp4 --to 25mb
|
|
204
222
|
```
|
|
205
223
|
|
|
224
|
+
每个文件单独出方案和结论,某个失败不影响后面的,`--json` 返回 `{ "files": [ ... ] }`。
|
|
225
|
+
多个输入时不能用 `--out`。
|
|
226
|
+
|
|
206
227
|
**为什么叫 halfsize?**
|
|
207
228
|
因为「能小多少」的诚实答案通常是「差不多一半,或者远不止一半」。它每次都会把真实数字告诉你。
|
|
208
229
|
|
|
209
230
|
## 路线图
|
|
210
231
|
|
|
211
232
|
- 硬件编码(NVENC / QSV / VideoToolbox)作为可选的快速路径
|
|
212
|
-
-
|
|
233
|
+
- 文件夹监听,新文件一出现就自动压
|
|
213
234
|
- 字幕和多音轨原样保留
|
|
214
235
|
- HDR 转 SDR(让 iPhone 拍的 HDR 视频保住颜色)
|
|
215
236
|
- 源已经是 AV1 时改用 AV1(`libsvtav1`)
|
|
216
237
|
- 单文件二进制,不依赖 Node.js
|
|
217
|
-
- 面向具体平台上限的预设(微信、Discord、邮件、X、Bluesky)
|
|
218
238
|
|
|
219
239
|
想要哪个就开 issue 说一声。欢迎 PR,见 [CONTRIBUTING.md](CONTRIBUTING.md)。
|
|
220
240
|
|
|
@@ -235,6 +255,8 @@ node bin/halfsize.mjs --web
|
|
|
235
255
|
|
|
236
256
|
MIT。随便用,随便改,随便发布。
|
|
237
257
|
|
|
258
|
+
版本变化见 [CHANGELOG.md](CHANGELOG.md)。
|
|
259
|
+
|
|
238
260
|
---
|
|
239
261
|
|
|
240
262
|
如果 `halfsize` 帮你少猜了一次码率,点个 star 能让下一个人也找到它。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "halfsize",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Compress any video to an exact target size, locally, and prove the quality did not drop.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
"src",
|
|
23
23
|
"web",
|
|
24
24
|
"README.md",
|
|
25
|
+
"README.zh-CN.md",
|
|
26
|
+
"CHANGELOG.md",
|
|
25
27
|
"LICENSE"
|
|
26
28
|
],
|
|
27
29
|
"scripts": {
|
package/src/cli.mjs
CHANGED
|
@@ -7,28 +7,34 @@ import { encode } from "./encode.mjs";
|
|
|
7
7
|
import { measureQuality, verdict } from "./quality.mjs";
|
|
8
8
|
import { bytes, duration, bitrate, ratio, parseSize, clock } from "./human.mjs";
|
|
9
9
|
|
|
10
|
-
const VERSION = "0.1.
|
|
10
|
+
const VERSION = "0.1.4";
|
|
11
11
|
|
|
12
12
|
const HELP = `
|
|
13
13
|
halfsize ${VERSION} - compress a video to an exact size, and prove it still looks the same.
|
|
14
14
|
|
|
15
15
|
Usage
|
|
16
16
|
halfsize <file> --to 25mb Compress to a target size
|
|
17
|
+
halfsize <file> --for discord Use a platform's upload limit
|
|
18
|
+
halfsize a.mp4 b.mp4 --to 10mb Compress several files in a row
|
|
17
19
|
halfsize <file> --to 25mb --codec h265
|
|
18
20
|
halfsize <file> --crf 23 Quality-first mode
|
|
19
21
|
halfsize --web Open the local app
|
|
22
|
+
halfsize --web --port 8080 Use a different port
|
|
20
23
|
halfsize --doctor Check FFmpeg and hardware encoders
|
|
21
24
|
|
|
22
25
|
Options
|
|
23
26
|
--to <size> Target size: 25mb, 700k, 1.5gb
|
|
27
|
+
--for <app> Target cap for discord, whatsapp, wechat, email, twitter
|
|
24
28
|
--crf <n> Quality mode (18 best / 23 balanced / 28 small)
|
|
25
29
|
--codec <name> h264 (default, compatible) or h265 (smaller)
|
|
26
30
|
--preset <name> ultrafast..veryslow (default: medium)
|
|
27
31
|
--out <file> Output path
|
|
32
|
+
--no-audio Drop the audio track
|
|
28
33
|
--no-verify Skip the SSIM/VMAF check
|
|
29
34
|
--no-downscale Never reduce resolution
|
|
30
35
|
--json Machine-readable result
|
|
31
36
|
--dry-run Print the plan, do not encode
|
|
37
|
+
--port <n> Port for --web (default 4174)
|
|
32
38
|
-h, --help Show this help
|
|
33
39
|
-v, --version Show version
|
|
34
40
|
|
|
@@ -38,7 +44,26 @@ const HELP = `
|
|
|
38
44
|
resolution, encodes twice, then measures the result with SSIM and VMAF.
|
|
39
45
|
`;
|
|
40
46
|
|
|
41
|
-
const DEFAULTS = { codec: "h264", preset: "medium", verify: true, downscale: true };
|
|
47
|
+
const DEFAULTS = { codec: "h264", preset: "medium", verify: true, downscale: true, audio: true };
|
|
48
|
+
|
|
49
|
+
// Current upload caps as people hit them. Caps change, so --to always wins if a
|
|
50
|
+
// preset is wrong for your account tier.
|
|
51
|
+
const PLATFORMS = {
|
|
52
|
+
discord: { bytes: 10 * 1_000_000, label: "Discord free (10 MB)" },
|
|
53
|
+
whatsapp: { bytes: 16 * 1_000_000, label: "WhatsApp media (16 MB)" },
|
|
54
|
+
wechat: { bytes: 25 * 1_000_000, label: "WeChat video (25 MB)" },
|
|
55
|
+
email: { bytes: 20 * 1_000_000, label: "Email attachment (20 MB)" },
|
|
56
|
+
twitter: { bytes: 512 * 1_000_000, label: "X / Twitter (512 MB)" },
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function platformFor(name) {
|
|
60
|
+
const key = String(name || "").toLowerCase();
|
|
61
|
+
const found = PLATFORMS[key];
|
|
62
|
+
if (!found) {
|
|
63
|
+
throw new Error(`Unknown platform "${name}". Use one of: ${Object.keys(PLATFORMS).join(", ")}.`);
|
|
64
|
+
}
|
|
65
|
+
return { key, ...found };
|
|
66
|
+
}
|
|
42
67
|
|
|
43
68
|
function parseArgs(argv) {
|
|
44
69
|
const args = { _: [], flags: { ...DEFAULTS } };
|
|
@@ -50,9 +75,12 @@ function parseArgs(argv) {
|
|
|
50
75
|
else if (a === "--dry-run") args.flags.dryRun = true;
|
|
51
76
|
else if (a === "--no-verify") args.flags.verify = false;
|
|
52
77
|
else if (a === "--no-downscale") args.flags.downscale = false;
|
|
78
|
+
else if (a === "--no-audio") args.flags.audio = false;
|
|
53
79
|
else if (a === "--web") args.flags.web = true;
|
|
54
80
|
else if (a === "--doctor") args.flags.doctor = true;
|
|
81
|
+
else if (a === "--port") args.flags.port = Number(argv[++i]);
|
|
55
82
|
else if (a === "--to") args.flags.to = argv[++i];
|
|
83
|
+
else if (a === "--for") args.flags.for = argv[++i];
|
|
56
84
|
else if (a === "--crf") args.flags.crf = Number(argv[++i]);
|
|
57
85
|
else if (a === "--codec") args.flags.codec = argv[++i];
|
|
58
86
|
else if (a === "--preset") args.flags.preset = argv[++i];
|
|
@@ -76,20 +104,55 @@ export async function main(argv) {
|
|
|
76
104
|
return;
|
|
77
105
|
}
|
|
78
106
|
if (flags.doctor) return doctor();
|
|
79
|
-
if (flags.web) return startWeb();
|
|
107
|
+
if (flags.web) return startWeb(flags.port);
|
|
80
108
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
if (!flags.to && flags.crf === undefined) {
|
|
84
|
-
throw new Error("Pick a target: --to 25mb, or
|
|
109
|
+
if (flags.to && flags.for) throw new Error("Use --to or --for, not both.");
|
|
110
|
+
const platform = flags.for ? platformFor(flags.for) : null;
|
|
111
|
+
if (!flags.to && !platform && flags.crf === undefined) {
|
|
112
|
+
throw new Error("Pick a target: --to 25mb, --for discord, or --crf 23 for quality mode.");
|
|
113
|
+
}
|
|
114
|
+
if (flags.out && args._.length > 1) {
|
|
115
|
+
throw new Error("--out only works with one input file. It would overwrite itself in a batch.");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const inputs = args._.map((file) => path.resolve(file));
|
|
119
|
+
const targetBytes = platform ? platform.bytes : flags.to ? parseSize(flags.to) : null;
|
|
120
|
+
const batch = inputs.length > 1;
|
|
121
|
+
|
|
122
|
+
const results = [];
|
|
123
|
+
let failed = 0;
|
|
124
|
+
for (const input of inputs) {
|
|
125
|
+
try {
|
|
126
|
+
results.push(await processFile(input, flags, targetBytes, platform, batch));
|
|
127
|
+
} catch (err) {
|
|
128
|
+
failed++;
|
|
129
|
+
results.push({ input: { file: input }, error: err?.message || String(err) });
|
|
130
|
+
if (!flags.json) console.error(`\n x ${path.basename(input)}: ${err?.message || err}`);
|
|
131
|
+
if (process.env.HALFSIZE_DEBUG) console.error(err?.stack);
|
|
132
|
+
}
|
|
85
133
|
}
|
|
86
134
|
|
|
87
|
-
|
|
135
|
+
if (flags.json) {
|
|
136
|
+
console.log(JSON.stringify(batch ? { files: results } : results[0], null, 2));
|
|
137
|
+
} else if (batch && !flags.dryRun) {
|
|
138
|
+
printBatch(results);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (failed) process.exitCode = 1;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function processFile(input, flags, targetBytes, platform, batch) {
|
|
145
|
+
if (!fs.existsSync(input)) throw new Error(`File not found: ${input}`);
|
|
146
|
+
|
|
88
147
|
const info = await probe(input);
|
|
89
148
|
|
|
90
149
|
if (targetBytes && targetBytes >= info.sizeBytes) {
|
|
91
|
-
|
|
92
|
-
|
|
150
|
+
if (batch) {
|
|
151
|
+
console.log(`\n ${path.basename(input)} already fits (${bytes(info.sizeBytes)}), skipped.`);
|
|
152
|
+
} else {
|
|
153
|
+
console.log(`\n This file is already ${bytes(info.sizeBytes)}, smaller than the ${bytes(targetBytes)} target.\n`);
|
|
154
|
+
}
|
|
155
|
+
return { input: { file: input, bytes: info.sizeBytes }, targetBytes, skipped: "already-smaller" };
|
|
93
156
|
}
|
|
94
157
|
|
|
95
158
|
const plan = planEncode(info, {
|
|
@@ -98,17 +161,20 @@ export async function main(argv) {
|
|
|
98
161
|
codec: flags.codec,
|
|
99
162
|
preset: flags.preset,
|
|
100
163
|
downscale: flags.downscale,
|
|
164
|
+
audio: flags.audio,
|
|
101
165
|
});
|
|
102
166
|
|
|
103
|
-
const outPath = flags.out
|
|
104
|
-
? path.resolve(flags.out)
|
|
105
|
-
: defaultOutput(input, flags, plan);
|
|
167
|
+
const outPath = flags.out ? path.resolve(flags.out) : defaultOutput(input, flags, plan);
|
|
106
168
|
|
|
107
|
-
if (!flags.json) printPlan(info, plan, flags, outPath);
|
|
169
|
+
if (!flags.json) printPlan(info, plan, flags, outPath, platform, !batch);
|
|
108
170
|
|
|
109
171
|
if (flags.dryRun) {
|
|
110
|
-
|
|
111
|
-
|
|
172
|
+
return {
|
|
173
|
+
info,
|
|
174
|
+
plan,
|
|
175
|
+
out: outPath,
|
|
176
|
+
dryRun: true,
|
|
177
|
+
};
|
|
112
178
|
}
|
|
113
179
|
|
|
114
180
|
const progress = new Progress(Boolean(process.stdout.isTTY) && !flags.json, info.duration);
|
|
@@ -120,7 +186,7 @@ export async function main(argv) {
|
|
|
120
186
|
}
|
|
121
187
|
|
|
122
188
|
let quality = { ssim: null, vmaf: null };
|
|
123
|
-
if (flags.verify
|
|
189
|
+
if (flags.verify) {
|
|
124
190
|
if (!flags.json) process.stdout.write(` measuring quality `);
|
|
125
191
|
quality = await measureQuality(input, outPath, info.duration);
|
|
126
192
|
if (!flags.json) process.stdout.write(`\r${" ".repeat(40)}\r`);
|
|
@@ -139,19 +205,34 @@ export async function main(argv) {
|
|
|
139
205
|
elapsedMs: result.elapsedMs,
|
|
140
206
|
};
|
|
141
207
|
|
|
142
|
-
if (flags.json)
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
}
|
|
208
|
+
if (!flags.json) printResult(summary, outPath);
|
|
209
|
+
return summary;
|
|
210
|
+
}
|
|
146
211
|
|
|
147
|
-
|
|
212
|
+
function printBatch(results) {
|
|
213
|
+
const done = results.filter((r) => r.output);
|
|
214
|
+
const skipped = results.filter((r) => r.skipped);
|
|
215
|
+
const errors = results.filter((r) => r.error);
|
|
216
|
+
console.log("");
|
|
217
|
+
if (done.length) {
|
|
218
|
+
const before = done.reduce((sum, r) => sum + r.input.bytes, 0);
|
|
219
|
+
const after = done.reduce((sum, r) => sum + r.output.bytes, 0);
|
|
220
|
+
console.log(` ${done.length} compressed ${bytes(before)} -> ${bytes(after)} ${ratio(before, after)} smaller`);
|
|
221
|
+
}
|
|
222
|
+
if (skipped.length) console.log(` ${skipped.length} skipped (already under target)`);
|
|
223
|
+
if (errors.length) console.log(` ${errors.length} failed`);
|
|
224
|
+
console.log("");
|
|
148
225
|
}
|
|
149
226
|
|
|
150
227
|
function defaultOutput(input, flags, plan) {
|
|
151
228
|
const dir = path.dirname(input);
|
|
152
229
|
const ext = path.extname(input);
|
|
153
230
|
const base = path.basename(input, ext);
|
|
154
|
-
const tag = flags.
|
|
231
|
+
const tag = flags.for
|
|
232
|
+
? String(flags.for).toLowerCase()
|
|
233
|
+
: flags.to
|
|
234
|
+
? String(flags.to).replace(/\s+/g, "").toLowerCase()
|
|
235
|
+
: `crf${flags.crf}`;
|
|
155
236
|
return path.join(dir, `${base}.${tag}.mp4`);
|
|
156
237
|
}
|
|
157
238
|
|
|
@@ -159,16 +240,18 @@ function line(label, value) {
|
|
|
159
240
|
console.log(` ${label.padEnd(10)} ${value}`);
|
|
160
241
|
}
|
|
161
242
|
|
|
162
|
-
function printPlan(info, plan, flags, outPath) {
|
|
163
|
-
console.log("");
|
|
164
|
-
console.log(` halfsize ${VERSION}`);
|
|
243
|
+
function printPlan(info, plan, flags, outPath, platform, showHeader = true) {
|
|
165
244
|
console.log("");
|
|
245
|
+
if (showHeader) {
|
|
246
|
+
console.log(` halfsize ${VERSION}`);
|
|
247
|
+
console.log("");
|
|
248
|
+
}
|
|
166
249
|
console.log(` ${path.basename(info.file)}`);
|
|
167
250
|
console.log(
|
|
168
251
|
` ${bytes(info.sizeBytes)} ${duration(info.duration)} ${info.video.width}x${info.video.height} ${info.video.fps.toFixed(2)} fps ${info.video.codec}`,
|
|
169
252
|
);
|
|
170
253
|
console.log("");
|
|
171
|
-
if (plan.mode === "size") line("target", bytes(plan.targetBytes));
|
|
254
|
+
if (plan.mode === "size") line("target", platform ? `${bytes(plan.targetBytes)} (${platform.label})` : bytes(plan.targetBytes));
|
|
172
255
|
else line("mode", `CRF ${plan.crf}`);
|
|
173
256
|
line(
|
|
174
257
|
"plan",
|
|
@@ -192,7 +275,7 @@ class Progress {
|
|
|
192
275
|
this.start = Date.now();
|
|
193
276
|
}
|
|
194
277
|
|
|
195
|
-
update({ ratio, speed }) {
|
|
278
|
+
update({ ratio, speed, pass, passes }) {
|
|
196
279
|
if (!this.enabled) return;
|
|
197
280
|
const now = Date.now();
|
|
198
281
|
if (now - this.lastPrint < 120 && ratio < 1) return;
|
|
@@ -203,8 +286,9 @@ class Progress {
|
|
|
203
286
|
const elapsed = (now - this.start) / 1000;
|
|
204
287
|
const eta = ratio > 0.02 ? (elapsed / ratio) * (1 - ratio) : 0;
|
|
205
288
|
const speedText = String(speed || "").replace("x", "");
|
|
289
|
+
const passText = passes > 1 ? `pass ${pass}/2 ` : "";
|
|
206
290
|
process.stdout.write(
|
|
207
|
-
`\r [${bar}] ${String(Math.round(ratio * 100)).padStart(3)}% ${speedText ? `${speedText}x` : ""} eta ${clock(eta)} `,
|
|
291
|
+
`\r [${bar}] ${String(Math.round(ratio * 100)).padStart(3)}% ${passText}${speedText ? `${speedText}x` : ""} eta ${clock(eta)} `,
|
|
208
292
|
);
|
|
209
293
|
}
|
|
210
294
|
|
|
@@ -271,9 +355,9 @@ async function doctor() {
|
|
|
271
355
|
console.log("");
|
|
272
356
|
}
|
|
273
357
|
|
|
274
|
-
async function startWeb() {
|
|
358
|
+
async function startWeb(port) {
|
|
275
359
|
const { startServer } = await import("./server.mjs");
|
|
276
|
-
const url = await startServer();
|
|
360
|
+
const url = await startServer(port ? { port } : {});
|
|
277
361
|
console.log("");
|
|
278
362
|
console.log(` halfsize is running at ${url}`);
|
|
279
363
|
console.log(" Press Ctrl+C to stop.");
|
package/src/encode.mjs
CHANGED
|
@@ -15,6 +15,10 @@ function buildFilters(plan) {
|
|
|
15
15
|
|
|
16
16
|
function baseArgs(input, plan, passlog) {
|
|
17
17
|
const args = ["-hide_banner", "-y", "-i", input];
|
|
18
|
+
// Map exactly the streams we mean to encode. Without this, ffmpeg can pick a
|
|
19
|
+
// cover art / thumbnail stream on files that carry one.
|
|
20
|
+
args.push("-map", "0:v:0");
|
|
21
|
+
if (plan.audioKbps) args.push("-map", "0:a:0?");
|
|
18
22
|
const filters = buildFilters(plan);
|
|
19
23
|
if (filters) args.push("-vf", filters);
|
|
20
24
|
args.push("-fps_mode", "cfr");
|
|
@@ -28,7 +32,7 @@ function baseArgs(input, plan, passlog) {
|
|
|
28
32
|
|
|
29
33
|
function audioArgs(plan) {
|
|
30
34
|
if (!plan.audioKbps) return ["-an"];
|
|
31
|
-
return ["-c:a", "aac", "-b:a", `${plan.audioKbps}k`, "-ac",
|
|
35
|
+
return ["-c:a", "aac", "-b:a", `${plan.audioKbps}k`, "-ac", String(plan.audioChannels === 1 ? 1 : 2)];
|
|
32
36
|
}
|
|
33
37
|
|
|
34
38
|
export async function encode(input, output, plan, info, onProgress = () => {}) {
|
|
@@ -46,7 +50,7 @@ export async function encode(input, output, plan, info, onProgress = () => {}) {
|
|
|
46
50
|
"-f", "null",
|
|
47
51
|
NULL_DEVICE,
|
|
48
52
|
];
|
|
49
|
-
await runFfmpeg(ffmpeg, pass1, info.duration, () => {});
|
|
53
|
+
await runFfmpeg(ffmpeg, pass1, info.duration, (p) => onProgress({ ...p, ratio: p.ratio * 0.5, pass: 1, passes: 2 }));
|
|
50
54
|
const pass2 = [
|
|
51
55
|
...baseArgs(input, plan, passlog),
|
|
52
56
|
"-b:v", `${plan.videoKbps}k`,
|
|
@@ -55,7 +59,7 @@ export async function encode(input, output, plan, info, onProgress = () => {}) {
|
|
|
55
59
|
"-movflags", "+faststart",
|
|
56
60
|
output,
|
|
57
61
|
];
|
|
58
|
-
await runFfmpeg(ffmpeg, pass2, info.duration, onProgress);
|
|
62
|
+
await runFfmpeg(ffmpeg, pass2, info.duration, (p) => onProgress({ ...p, ratio: 0.5 + p.ratio * 0.5, pass: 2, passes: 2 }));
|
|
59
63
|
} else {
|
|
60
64
|
const args = [...baseArgs(input, plan, null)];
|
|
61
65
|
if (plan.mode === "size") {
|
|
@@ -64,7 +68,7 @@ export async function encode(input, output, plan, info, onProgress = () => {}) {
|
|
|
64
68
|
args.push("-crf", String(plan.crf));
|
|
65
69
|
}
|
|
66
70
|
args.push(...audioArgs(plan), "-movflags", "+faststart", output);
|
|
67
|
-
await runFfmpeg(ffmpeg, args, info.duration, onProgress);
|
|
71
|
+
await runFfmpeg(ffmpeg, args, info.duration, (p) => onProgress({ ...p, pass: 1, passes: 1 }));
|
|
68
72
|
}
|
|
69
73
|
} finally {
|
|
70
74
|
cleanup(passlog);
|
package/src/plan.mjs
CHANGED
|
@@ -62,6 +62,8 @@ export function planEncode(info, options = {}) {
|
|
|
62
62
|
|
|
63
63
|
if (!info.duration) throw new Error("Could not read the duration of this file.");
|
|
64
64
|
|
|
65
|
+
const audioChannels = info.audio?.channels === 1 ? 1 : 2;
|
|
66
|
+
|
|
65
67
|
if (options.crf !== undefined) {
|
|
66
68
|
return {
|
|
67
69
|
mode: "quality",
|
|
@@ -74,7 +76,8 @@ export function planEncode(info, options = {}) {
|
|
|
74
76
|
height: info.video.height,
|
|
75
77
|
fps: capFps(info.video.fps),
|
|
76
78
|
videoKbps: null,
|
|
77
|
-
audioKbps: audioBitrate(info, 2000),
|
|
79
|
+
audioKbps: options.audio === false ? 0 : audioBitrate(info, 2000),
|
|
80
|
+
audioChannels,
|
|
78
81
|
targetBytes: null,
|
|
79
82
|
twoPass: false,
|
|
80
83
|
downscaled: false,
|
|
@@ -87,7 +90,7 @@ export function planEncode(info, options = {}) {
|
|
|
87
90
|
if (!options.targetBytes) throw new Error("A target size is required.");
|
|
88
91
|
|
|
89
92
|
const totalKbps = ((options.targetBytes * 8) / info.duration / 1000) * 0.97;
|
|
90
|
-
const audioKbps = audioBitrate(info, totalKbps);
|
|
93
|
+
const audioKbps = options.audio === false ? 0 : audioBitrate(info, totalKbps);
|
|
91
94
|
const videoKbps = Math.max(40, Math.round(totalKbps - audioKbps));
|
|
92
95
|
const fps = capFps(info.video.fps);
|
|
93
96
|
|
|
@@ -131,6 +134,7 @@ export function planEncode(info, options = {}) {
|
|
|
131
134
|
fps,
|
|
132
135
|
videoKbps,
|
|
133
136
|
audioKbps,
|
|
137
|
+
audioChannels,
|
|
134
138
|
targetBytes: options.targetBytes,
|
|
135
139
|
twoPass: Boolean(codec.twoPass),
|
|
136
140
|
downscaled,
|
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%";
|