halfsize 0.1.3 → 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 +27 -5
- package/README.zh-CN.md +26 -5
- package/package.json +3 -1
- package/src/cli.mjs +106 -26
- package/src/encode.mjs +5 -1
- package/src/plan.mjs +6 -2
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,10 +168,12 @@ 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
|
|
@@ -172,6 +187,9 @@ halfsize <file> [options]
|
|
|
172
187
|
|
|
173
188
|
Sizes accept `b`, `k`/`kb`, `m`/`mb`, `g`/`gb`, with or without a space. No unit means MB.
|
|
174
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
|
+
|
|
175
193
|
Exit codes: `0` success, `1` error. `--json` returns the plan, the measured quality, and the
|
|
176
194
|
target accuracy, which makes it easy to wrap in a script.
|
|
177
195
|
|
|
@@ -211,12 +229,15 @@ at the same quality but can be awkward in some editors and older hardware. Defau
|
|
|
211
229
|
Yes. It is plain Node.js and FFmpeg.
|
|
212
230
|
|
|
213
231
|
**Can I batch a folder?**
|
|
214
|
-
|
|
232
|
+
Yes. List the files, or let your shell expand them:
|
|
215
233
|
|
|
216
234
|
```bash
|
|
217
|
-
|
|
235
|
+
npx halfsize *.mp4 --to 25mb
|
|
218
236
|
```
|
|
219
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
|
+
|
|
220
241
|
**Why "halfsize"?**
|
|
221
242
|
Because the honest answer to "how much smaller will it get?" is usually "about half, or a lot
|
|
222
243
|
more than half". The tool reports the real number either way.
|
|
@@ -224,12 +245,11 @@ more than half". The tool reports the real number either way.
|
|
|
224
245
|
## Roadmap
|
|
225
246
|
|
|
226
247
|
- Hardware encoding (NVENC / QSV / VideoToolbox) as an opt-in fast path
|
|
227
|
-
-
|
|
248
|
+
- A folder watcher that compresses new files as they appear
|
|
228
249
|
- Subtitle and multiple audio track passthrough
|
|
229
250
|
- HDR-to-SDR tonemapping, so iPhone HDR clips keep their colors
|
|
230
251
|
- AV1 (`libsvtav1`) when the source is already AV1-friendly
|
|
231
252
|
- A single-file binary, so Node.js is not required
|
|
232
|
-
- Presets for specific platform caps (WeChat, Discord, email, X, Bluesky)
|
|
233
253
|
|
|
234
254
|
If you want one of these, open an issue and say which. Pull requests are welcome; see
|
|
235
255
|
[CONTRIBUTING.md](CONTRIBUTING.md).
|
|
@@ -251,6 +271,8 @@ it to a target, probes the output, and asserts the size and SSIM. No fixtures in
|
|
|
251
271
|
|
|
252
272
|
MIT. Use it, fork it, ship it.
|
|
253
273
|
|
|
274
|
+
See [CHANGELOG.md](CHANGELOG.md) for what changed.
|
|
275
|
+
|
|
254
276
|
---
|
|
255
277
|
|
|
256
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,10 +158,12 @@ 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 机器可读结果
|
|
@@ -163,6 +177,9 @@ halfsize <file> [options]
|
|
|
163
177
|
|
|
164
178
|
大小支持 `b`、`k`/`kb`、`m`/`mb`、`g`/`gb`,中间可以有空格。不写单位按 MB 算。
|
|
165
179
|
|
|
180
|
+
`--for` 用的是当前免费档上限:`discord` 10 MB、`whatsapp` 16 MB、`wechat` 25 MB、
|
|
181
|
+
`email` 20 MB、`twitter` 512 MB。平台上限会变,写死不如随时用 `--to` 覆盖。
|
|
182
|
+
|
|
166
183
|
退出码:`0` 成功,`1` 出错。`--json` 会返回方案、画质测量和目标误差,方便包进脚本。
|
|
167
184
|
|
|
168
185
|
## 安装说明
|
|
@@ -198,24 +215,26 @@ H.264 到处都能播,包括老安卓机和浏览器。H.265 同画质大约
|
|
|
198
215
|
能。就是 Node.js + FFmpeg,没有平台特定的东西。
|
|
199
216
|
|
|
200
217
|
**能批量压一个文件夹吗?**
|
|
201
|
-
|
|
218
|
+
能。直接把文件列出来,或者让 shell 展开:
|
|
202
219
|
|
|
203
220
|
```bash
|
|
204
|
-
|
|
221
|
+
npx halfsize *.mp4 --to 25mb
|
|
205
222
|
```
|
|
206
223
|
|
|
224
|
+
每个文件单独出方案和结论,某个失败不影响后面的,`--json` 返回 `{ "files": [ ... ] }`。
|
|
225
|
+
多个输入时不能用 `--out`。
|
|
226
|
+
|
|
207
227
|
**为什么叫 halfsize?**
|
|
208
228
|
因为「能小多少」的诚实答案通常是「差不多一半,或者远不止一半」。它每次都会把真实数字告诉你。
|
|
209
229
|
|
|
210
230
|
## 路线图
|
|
211
231
|
|
|
212
232
|
- 硬件编码(NVENC / QSV / VideoToolbox)作为可选的快速路径
|
|
213
|
-
-
|
|
233
|
+
- 文件夹监听,新文件一出现就自动压
|
|
214
234
|
- 字幕和多音轨原样保留
|
|
215
235
|
- HDR 转 SDR(让 iPhone 拍的 HDR 视频保住颜色)
|
|
216
236
|
- 源已经是 AV1 时改用 AV1(`libsvtav1`)
|
|
217
237
|
- 单文件二进制,不依赖 Node.js
|
|
218
|
-
- 面向具体平台上限的预设(微信、Discord、邮件、X、Bluesky)
|
|
219
238
|
|
|
220
239
|
想要哪个就开 issue 说一声。欢迎 PR,见 [CONTRIBUTING.md](CONTRIBUTING.md)。
|
|
221
240
|
|
|
@@ -236,6 +255,8 @@ node bin/halfsize.mjs --web
|
|
|
236
255
|
|
|
237
256
|
MIT。随便用,随便改,随便发布。
|
|
238
257
|
|
|
258
|
+
版本变化见 [CHANGELOG.md](CHANGELOG.md)。
|
|
259
|
+
|
|
239
260
|
---
|
|
240
261
|
|
|
241
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,13 +7,15 @@ 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,10 +24,12 @@ const HELP = `
|
|
|
22
24
|
|
|
23
25
|
Options
|
|
24
26
|
--to <size> Target size: 25mb, 700k, 1.5gb
|
|
27
|
+
--for <app> Target cap for discord, whatsapp, wechat, email, twitter
|
|
25
28
|
--crf <n> Quality mode (18 best / 23 balanced / 28 small)
|
|
26
29
|
--codec <name> h264 (default, compatible) or h265 (smaller)
|
|
27
30
|
--preset <name> ultrafast..veryslow (default: medium)
|
|
28
31
|
--out <file> Output path
|
|
32
|
+
--no-audio Drop the audio track
|
|
29
33
|
--no-verify Skip the SSIM/VMAF check
|
|
30
34
|
--no-downscale Never reduce resolution
|
|
31
35
|
--json Machine-readable result
|
|
@@ -40,7 +44,26 @@ const HELP = `
|
|
|
40
44
|
resolution, encodes twice, then measures the result with SSIM and VMAF.
|
|
41
45
|
`;
|
|
42
46
|
|
|
43
|
-
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
|
+
}
|
|
44
67
|
|
|
45
68
|
function parseArgs(argv) {
|
|
46
69
|
const args = { _: [], flags: { ...DEFAULTS } };
|
|
@@ -52,10 +75,12 @@ function parseArgs(argv) {
|
|
|
52
75
|
else if (a === "--dry-run") args.flags.dryRun = true;
|
|
53
76
|
else if (a === "--no-verify") args.flags.verify = false;
|
|
54
77
|
else if (a === "--no-downscale") args.flags.downscale = false;
|
|
78
|
+
else if (a === "--no-audio") args.flags.audio = false;
|
|
55
79
|
else if (a === "--web") args.flags.web = true;
|
|
56
80
|
else if (a === "--doctor") args.flags.doctor = true;
|
|
57
81
|
else if (a === "--port") args.flags.port = Number(argv[++i]);
|
|
58
82
|
else if (a === "--to") args.flags.to = argv[++i];
|
|
83
|
+
else if (a === "--for") args.flags.for = argv[++i];
|
|
59
84
|
else if (a === "--crf") args.flags.crf = Number(argv[++i]);
|
|
60
85
|
else if (a === "--codec") args.flags.codec = argv[++i];
|
|
61
86
|
else if (a === "--preset") args.flags.preset = argv[++i];
|
|
@@ -81,18 +106,53 @@ export async function main(argv) {
|
|
|
81
106
|
if (flags.doctor) return doctor();
|
|
82
107
|
if (flags.web) return startWeb(flags.port);
|
|
83
108
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if (!flags.to && flags.crf === undefined) {
|
|
87
|
-
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
|
+
}
|
|
88
133
|
}
|
|
89
134
|
|
|
90
|
-
|
|
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
|
+
|
|
91
147
|
const info = await probe(input);
|
|
92
148
|
|
|
93
149
|
if (targetBytes && targetBytes >= info.sizeBytes) {
|
|
94
|
-
|
|
95
|
-
|
|
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" };
|
|
96
156
|
}
|
|
97
157
|
|
|
98
158
|
const plan = planEncode(info, {
|
|
@@ -101,17 +161,20 @@ export async function main(argv) {
|
|
|
101
161
|
codec: flags.codec,
|
|
102
162
|
preset: flags.preset,
|
|
103
163
|
downscale: flags.downscale,
|
|
164
|
+
audio: flags.audio,
|
|
104
165
|
});
|
|
105
166
|
|
|
106
|
-
const outPath = flags.out
|
|
107
|
-
? path.resolve(flags.out)
|
|
108
|
-
: defaultOutput(input, flags, plan);
|
|
167
|
+
const outPath = flags.out ? path.resolve(flags.out) : defaultOutput(input, flags, plan);
|
|
109
168
|
|
|
110
|
-
if (!flags.json) printPlan(info, plan, flags, outPath);
|
|
169
|
+
if (!flags.json) printPlan(info, plan, flags, outPath, platform, !batch);
|
|
111
170
|
|
|
112
171
|
if (flags.dryRun) {
|
|
113
|
-
|
|
114
|
-
|
|
172
|
+
return {
|
|
173
|
+
info,
|
|
174
|
+
plan,
|
|
175
|
+
out: outPath,
|
|
176
|
+
dryRun: true,
|
|
177
|
+
};
|
|
115
178
|
}
|
|
116
179
|
|
|
117
180
|
const progress = new Progress(Boolean(process.stdout.isTTY) && !flags.json, info.duration);
|
|
@@ -123,7 +186,7 @@ export async function main(argv) {
|
|
|
123
186
|
}
|
|
124
187
|
|
|
125
188
|
let quality = { ssim: null, vmaf: null };
|
|
126
|
-
if (flags.verify
|
|
189
|
+
if (flags.verify) {
|
|
127
190
|
if (!flags.json) process.stdout.write(` measuring quality `);
|
|
128
191
|
quality = await measureQuality(input, outPath, info.duration);
|
|
129
192
|
if (!flags.json) process.stdout.write(`\r${" ".repeat(40)}\r`);
|
|
@@ -142,19 +205,34 @@ export async function main(argv) {
|
|
|
142
205
|
elapsedMs: result.elapsedMs,
|
|
143
206
|
};
|
|
144
207
|
|
|
145
|
-
if (flags.json)
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
}
|
|
208
|
+
if (!flags.json) printResult(summary, outPath);
|
|
209
|
+
return summary;
|
|
210
|
+
}
|
|
149
211
|
|
|
150
|
-
|
|
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("");
|
|
151
225
|
}
|
|
152
226
|
|
|
153
227
|
function defaultOutput(input, flags, plan) {
|
|
154
228
|
const dir = path.dirname(input);
|
|
155
229
|
const ext = path.extname(input);
|
|
156
230
|
const base = path.basename(input, ext);
|
|
157
|
-
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}`;
|
|
158
236
|
return path.join(dir, `${base}.${tag}.mp4`);
|
|
159
237
|
}
|
|
160
238
|
|
|
@@ -162,16 +240,18 @@ function line(label, value) {
|
|
|
162
240
|
console.log(` ${label.padEnd(10)} ${value}`);
|
|
163
241
|
}
|
|
164
242
|
|
|
165
|
-
function printPlan(info, plan, flags, outPath) {
|
|
166
|
-
console.log("");
|
|
167
|
-
console.log(` halfsize ${VERSION}`);
|
|
243
|
+
function printPlan(info, plan, flags, outPath, platform, showHeader = true) {
|
|
168
244
|
console.log("");
|
|
245
|
+
if (showHeader) {
|
|
246
|
+
console.log(` halfsize ${VERSION}`);
|
|
247
|
+
console.log("");
|
|
248
|
+
}
|
|
169
249
|
console.log(` ${path.basename(info.file)}`);
|
|
170
250
|
console.log(
|
|
171
251
|
` ${bytes(info.sizeBytes)} ${duration(info.duration)} ${info.video.width}x${info.video.height} ${info.video.fps.toFixed(2)} fps ${info.video.codec}`,
|
|
172
252
|
);
|
|
173
253
|
console.log("");
|
|
174
|
-
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));
|
|
175
255
|
else line("mode", `CRF ${plan.crf}`);
|
|
176
256
|
line(
|
|
177
257
|
"plan",
|
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 = () => {}) {
|
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,
|