halfsize 0.1.3 → 0.1.5
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 +40 -0
- package/README.md +77 -15
- package/README.zh-CN.md +78 -12
- package/package.json +59 -47
- package/src/cli.mjs +107 -26
- package/src/encode.mjs +5 -1
- package/src/plan.mjs +6 -2
- package/web/index.html +11 -12
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.5
|
|
4
|
+
|
|
5
|
+
- `--for discord` now targets 20 MB (Discord raised its free upload cap from 10 MB in August 2026)
|
|
6
|
+
- `--for wechat` now targets 20 MB (WeChat's chat-video cap, per its help center)
|
|
7
|
+
- The web app's presets and copy match the current caps
|
|
8
|
+
- `npx` installs behind a restrictive network can use the npmmirror FFmpeg mirror (see Install notes)
|
|
9
|
+
|
|
10
|
+
## 0.1.4
|
|
11
|
+
|
|
12
|
+
- Batch mode: `halfsize a.mp4 b.mp4 --to 10mb` compresses several files in a row
|
|
13
|
+
- `--for discord|whatsapp|wechat|email|twitter` picks the platform's upload cap for you
|
|
14
|
+
- `--no-audio` drops the audio track and spends the bits on video instead
|
|
15
|
+
- Streams are mapped explicitly, so cover art and extra tracks cannot be picked by mistake
|
|
16
|
+
- Mono audio stays mono instead of being silently upmixed to stereo
|
|
17
|
+
|
|
18
|
+
## 0.1.3
|
|
19
|
+
|
|
20
|
+
- Two-pass progress now spans both passes and shows `pass 1/2`
|
|
21
|
+
- `--web --port <n>` to pick a port
|
|
22
|
+
- Pass logs are cleaned up even when an encode fails
|
|
23
|
+
|
|
24
|
+
## 0.1.2
|
|
25
|
+
|
|
26
|
+
- npm release is live
|
|
27
|
+
- CI badge in the README
|
|
28
|
+
|
|
29
|
+
## 0.1.1
|
|
30
|
+
|
|
31
|
+
- `--version` no longer falls through to the help screen
|
|
32
|
+
- Force CFR so two-pass encodes of Matroska/VFR sources stay on target
|
|
33
|
+
- Always emit MP4 from the CLI, even for MKV/AVI sources
|
|
34
|
+
|
|
35
|
+
## 0.1.0
|
|
36
|
+
|
|
37
|
+
- First release: exact target size, resolution ladder, two-pass H.264/H.265
|
|
38
|
+
- SSIM sampling and VMAF measurement with a plain-language verdict
|
|
39
|
+
- Local web app with drag and drop, platform presets, and a share card
|
|
40
|
+
- `--json`, `--dry-run`, `--doctor`, and `--no-verify`
|
package/README.md
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
<p align="center">
|
|
21
21
|
<a href="README.zh-CN.md">中文说明</a> ·
|
|
22
|
+
<a href="https://YKLovePython.github.io/halfsize/">Website & guides</a> ·
|
|
22
23
|
<a href="#quick-start">Quick start</a> ·
|
|
23
24
|
<a href="#how-it-works">How it works</a> ·
|
|
24
25
|
<a href="#faq">FAQ</a>
|
|
@@ -26,7 +27,7 @@
|
|
|
26
27
|
|
|
27
28
|
---
|
|
28
29
|
|
|
29
|
-
WeChat
|
|
30
|
+
WeChat and Discord both stop at **20 MB**. Email chokes around the same.
|
|
30
31
|
So you open a video editor, guess a bitrate, export, check the size, export again, and still
|
|
31
32
|
can't tell whether the picture got worse.
|
|
32
33
|
|
|
@@ -40,7 +41,7 @@ It computes the bitrate, picks the resolution that actually fits, encodes twice,
|
|
|
40
41
|
the result with **SSIM** and **VMAF** so you know whether it still looks the same.
|
|
41
42
|
|
|
42
43
|
```
|
|
43
|
-
halfsize 0.1.
|
|
44
|
+
halfsize 0.1.5
|
|
44
45
|
|
|
45
46
|
source.mp4
|
|
46
47
|
23.2 MB 8s 1920x1080 30.00 fps h264
|
|
@@ -51,9 +52,9 @@ the result with **SSIM** and **VMAF** so you know whether it still looks the sam
|
|
|
51
52
|
estimate 1.94 MB (12x smaller)
|
|
52
53
|
output source.2mb.mp4
|
|
53
54
|
|
|
54
|
-
measuring quality done in
|
|
55
|
+
measuring quality done in 1s
|
|
55
56
|
|
|
56
|
-
23.2 MB -> 1.99 MB 12x smaller 0.
|
|
57
|
+
23.2 MB -> 1.99 MB 12x smaller 0.4% under target
|
|
57
58
|
VMAF 84.4 SSIM 0.968 slight softening
|
|
58
59
|
|
|
59
60
|
-> C:\Videos\source.2mb.mp4
|
|
@@ -61,7 +62,7 @@ the result with **SSIM** and **VMAF** so you know whether it still looks the sam
|
|
|
61
62
|
|
|
62
63
|
## Why this exists
|
|
63
64
|
|
|
64
|
-
"Make it smaller" is a solved problem. **"Make it fit in exactly
|
|
65
|
+
"Make it smaller" is a solved problem. **"Make it fit in exactly 20 MB, and tell me if it
|
|
65
66
|
still looks right"** is the part every tool makes you guess at.
|
|
66
67
|
|
|
67
68
|
- HandBrake is excellent, but it asks you to think in codecs, RF values, and presets.
|
|
@@ -87,6 +88,12 @@ be downloaded, `halfsize` uses your system install instead.
|
|
|
87
88
|
# compress to a size
|
|
88
89
|
npx halfsize video.mp4 --to 25mb
|
|
89
90
|
|
|
91
|
+
# or just name the app you are uploading to
|
|
92
|
+
npx halfsize video.mp4 --for discord
|
|
93
|
+
|
|
94
|
+
# several files in one go
|
|
95
|
+
npx halfsize clip1.mp4 clip2.mp4 clip3.mp4 --to 10mb
|
|
96
|
+
|
|
90
97
|
# smaller file, same quality budget
|
|
91
98
|
npx halfsize video.mp4 --to 10mb --codec h265
|
|
92
99
|
|
|
@@ -112,7 +119,7 @@ npm install -g halfsize
|
|
|
112
119
|
npx halfsize --web
|
|
113
120
|
```
|
|
114
121
|
|
|
115
|
-
Opens `http://127.0.0.1:4174` — drag a video in, pick
|
|
122
|
+
Opens `http://127.0.0.1:4174` — drag a video in, pick 20 MB / 16 MB / 5 MB or type
|
|
116
123
|
your own limit, and watch the encode. The file goes to a temporary folder **on your own
|
|
117
124
|
machine**; nothing is sent anywhere.
|
|
118
125
|
|
|
@@ -123,6 +130,19 @@ machine**; nothing is sent anywhere.
|
|
|
123
130
|
After each run you can download a **share card** (the image at the top of this README) with the
|
|
124
131
|
real numbers on it.
|
|
125
132
|
|
|
133
|
+
Step-by-step guides with measured quality numbers:
|
|
134
|
+
[Discord 20 MB](https://YKLovePython.github.io/halfsize/discord/) ·
|
|
135
|
+
[WhatsApp 16 MB](https://YKLovePython.github.io/halfsize/whatsapp/) ·
|
|
136
|
+
[WeChat 20 MB](https://YKLovePython.github.io/halfsize/wechat/) ·
|
|
137
|
+
[any 20 MB limit](https://YKLovePython.github.io/halfsize/20mb/).
|
|
138
|
+
|
|
139
|
+
### Use it from an agent
|
|
140
|
+
|
|
141
|
+
The repo ships a skill in [`skills/halfsize/SKILL.md`](skills/halfsize/SKILL.md). Drop that
|
|
142
|
+
folder into your agent's skills directory (for Claude Code: `.claude/skills/halfsize/`) and the
|
|
143
|
+
agent can turn "make this fit in 25 MB" into a compression plus a measured quality report,
|
|
144
|
+
instead of guessing a bitrate.
|
|
145
|
+
|
|
126
146
|
## How it works
|
|
127
147
|
|
|
128
148
|
1. **Probe.** Read duration, resolution, frame rate, audio bitrate, rotation, and HDR metadata.
|
|
@@ -149,16 +169,37 @@ below 70 clearly degraded
|
|
|
149
169
|
VMAF is Netflix's perceptual metric; it correlates with human judgement far better than PSNR
|
|
150
170
|
or bitrate. It is not magic, but it is a much better answer than "trust me".
|
|
151
171
|
|
|
172
|
+
## What each size costs
|
|
173
|
+
|
|
174
|
+
Same 60-second 1080p clip, default settings, measured on a normal laptop:
|
|
175
|
+
|
|
176
|
+
| Target | Output | Resolution | Video bitrate | VMAF | Verdict |
|
|
177
|
+
|---|---|---|---|---|---|
|
|
178
|
+
| 20 MB | 19.45 MB | 1280×720 | 2.46 Mbps | 88.3 | slight softening |
|
|
179
|
+
| 16 MB | 15.59 MB | 960×540 | 1.94 Mbps | 84.8 | slight softening |
|
|
180
|
+
| 10 MB | 9.77 MB | 960×540 | 1.17 Mbps | 82.9 | slight softening |
|
|
181
|
+
| 5 MB | 4.93 MB | 640×360 | 0.55 Mbps | 72.0 | visible loss |
|
|
182
|
+
|
|
183
|
+
Source: 60 s, 1920×1080, 30 fps, 18 Mbps H.264 (136 MB). VMAF is measured against the
|
|
184
|
+
source at the midpoint, SSIM at three points. Real numbers move with content: a talking head
|
|
185
|
+
does much better than confetti.
|
|
186
|
+
|
|
187
|
+
At 20 MB the tool picked 720p on purpose. Sixty seconds of 1080p at 2.4 Mbps looks worse than
|
|
188
|
+
720p at the same bits. `--no-downscale` overrides that, and `--codec h265` buys roughly 30%
|
|
189
|
+
more quality at the same size if your player supports it.
|
|
190
|
+
|
|
152
191
|
## CLI reference
|
|
153
192
|
|
|
154
193
|
```
|
|
155
194
|
halfsize <file> [options]
|
|
156
195
|
|
|
157
196
|
--to <size> Target size: 25mb, 700k, 1.5gb
|
|
197
|
+
--for <app> Target cap for discord, whatsapp, wechat, email, twitter
|
|
158
198
|
--crf <n> Quality mode instead of size mode (18 best, 23 balanced, 28 small)
|
|
159
199
|
--codec <name> h264 (default, maximum compatibility) or h265 (about 30% smaller)
|
|
160
200
|
--preset <name> ultrafast .. veryslow (default: medium)
|
|
161
201
|
--out <file> Output path
|
|
202
|
+
--no-audio Drop the audio track
|
|
162
203
|
--no-verify Skip SSIM/VMAF
|
|
163
204
|
--no-downscale Never reduce resolution (the target may then be missed)
|
|
164
205
|
--json Machine-readable result
|
|
@@ -172,16 +213,24 @@ halfsize <file> [options]
|
|
|
172
213
|
|
|
173
214
|
Sizes accept `b`, `k`/`kb`, `m`/`mb`, `g`/`gb`, with or without a space. No unit means MB.
|
|
174
215
|
|
|
216
|
+
`--for` uses the current free-tier caps (checked November 2026): `discord` 20 MB,
|
|
217
|
+
`whatsapp` 16 MB, `wechat` 20 MB, `email` 20 MB, `twitter` 512 MB. Caps change, so `--to`
|
|
218
|
+
always overrides.
|
|
219
|
+
|
|
175
220
|
Exit codes: `0` success, `1` error. `--json` returns the plan, the measured quality, and the
|
|
176
221
|
target accuracy, which makes it easy to wrap in a script.
|
|
177
222
|
|
|
178
223
|
## Install notes
|
|
179
224
|
|
|
180
|
-
`ffmpeg-static`
|
|
181
|
-
|
|
182
|
-
|
|
225
|
+
`ffmpeg-static` downloads a prebuilt FFmpeg during `npm install`; FFprobe ships inside
|
|
226
|
+
`ffprobe-static`, so it needs no download. If you are behind a proxy or a TLS-inspecting
|
|
227
|
+
network, that download can fail. Point it at a mirror, or use a build you already have:
|
|
183
228
|
|
|
184
229
|
```bash
|
|
230
|
+
# use the npmmirror binary mirror (fast in mainland China)
|
|
231
|
+
FFMPEG_BINARIES_URL=https://registry.npmmirror.com/-/binary/ffmpeg-static npx halfsize video.mp4 --to 25mb
|
|
232
|
+
|
|
233
|
+
# or an FFmpeg already on disk
|
|
185
234
|
HALFSIZE_FFMPEG=/path/to/ffmpeg HALFSIZE_FFPROBE=/path/to/ffprobe npx halfsize video.mp4 --to 25mb
|
|
186
235
|
```
|
|
187
236
|
|
|
@@ -194,14 +243,23 @@ Debian/Ubuntu, `sudo apt install ffmpeg`. Run `npx halfsize --doctor` to see wha
|
|
|
194
243
|
No. The CLI never touches the network. The web app talks only to `127.0.0.1`, and uploaded
|
|
195
244
|
files go to a temp directory that is deleted when the process exits.
|
|
196
245
|
|
|
246
|
+
**How do I get a video under Discord's 20 MB limit?**
|
|
247
|
+
`npx halfsize clip.mp4 --for discord` targets the free-tier cap (20 MB as of late 2026) and
|
|
248
|
+
writes a normal MP4 that plays in Discord's preview. Long 1080p clips usually come out at 720p;
|
|
249
|
+
that resolution drop is what buys the size.
|
|
250
|
+
|
|
251
|
+
**WeChat says the video is too large. What now?**
|
|
252
|
+
`npx halfsize clip.mp4 --for wechat` targets the 20 MB chat-video cap. If you need it smaller
|
|
253
|
+
still, pass `--to 10mb` (or any size) instead.
|
|
254
|
+
|
|
197
255
|
**Why did it lower my resolution?**
|
|
198
|
-
Because
|
|
256
|
+
Because 20 MB of 4K is not enough bits to look good. Lowering resolution and keeping the
|
|
199
257
|
bitrate per pixel sane almost always looks better than keeping 4K and starving it. Use
|
|
200
258
|
`--no-downscale` if you want to override that.
|
|
201
259
|
|
|
202
260
|
**Why is the output slightly under the target, not exactly on it?**
|
|
203
261
|
Container overhead and two-pass ABR both vary a little. `halfsize` aims ~3% under so it never
|
|
204
|
-
overshoots a hard platform cap. In the example above it landed 0.
|
|
262
|
+
overshoots a hard platform cap. In the example above it landed 0.4% under.
|
|
205
263
|
|
|
206
264
|
**H.264 or H.265?**
|
|
207
265
|
H.264 plays everywhere, including old Android phones and browsers. H.265 is roughly 30% smaller
|
|
@@ -211,12 +269,15 @@ at the same quality but can be awkward in some editors and older hardware. Defau
|
|
|
211
269
|
Yes. It is plain Node.js and FFmpeg.
|
|
212
270
|
|
|
213
271
|
**Can I batch a folder?**
|
|
214
|
-
|
|
272
|
+
Yes. List the files, or let your shell expand them:
|
|
215
273
|
|
|
216
274
|
```bash
|
|
217
|
-
|
|
275
|
+
npx halfsize *.mp4 --to 25mb
|
|
218
276
|
```
|
|
219
277
|
|
|
278
|
+
Each file gets its own plan and verdict, a failure does not stop the rest, and `--json` returns
|
|
279
|
+
`{ "files": [ ... ] }`. `--out` is allowed only when there is a single input.
|
|
280
|
+
|
|
220
281
|
**Why "halfsize"?**
|
|
221
282
|
Because the honest answer to "how much smaller will it get?" is usually "about half, or a lot
|
|
222
283
|
more than half". The tool reports the real number either way.
|
|
@@ -224,12 +285,11 @@ more than half". The tool reports the real number either way.
|
|
|
224
285
|
## Roadmap
|
|
225
286
|
|
|
226
287
|
- Hardware encoding (NVENC / QSV / VideoToolbox) as an opt-in fast path
|
|
227
|
-
-
|
|
288
|
+
- A folder watcher that compresses new files as they appear
|
|
228
289
|
- Subtitle and multiple audio track passthrough
|
|
229
290
|
- HDR-to-SDR tonemapping, so iPhone HDR clips keep their colors
|
|
230
291
|
- AV1 (`libsvtav1`) when the source is already AV1-friendly
|
|
231
292
|
- A single-file binary, so Node.js is not required
|
|
232
|
-
- Presets for specific platform caps (WeChat, Discord, email, X, Bluesky)
|
|
233
293
|
|
|
234
294
|
If you want one of these, open an issue and say which. Pull requests are welcome; see
|
|
235
295
|
[CONTRIBUTING.md](CONTRIBUTING.md).
|
|
@@ -251,6 +311,8 @@ it to a target, probes the output, and asserts the size and SSIM. No fixtures in
|
|
|
251
311
|
|
|
252
312
|
MIT. Use it, fork it, ship it.
|
|
253
313
|
|
|
314
|
+
See [CHANGELOG.md](CHANGELOG.md) for what changed.
|
|
315
|
+
|
|
254
316
|
---
|
|
255
317
|
|
|
256
318
|
If `halfsize` saved you from guessing a bitrate, a star helps the next person find it.
|
package/README.zh-CN.md
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
<p align="center">
|
|
21
21
|
<a href="README.md">English</a> ·
|
|
22
|
+
<a href="https://YKLovePython.github.io/halfsize/zh/">官网与教程</a> ·
|
|
22
23
|
<a href="#快速开始">快速开始</a> ·
|
|
23
24
|
<a href="#它怎么工作">它怎么工作</a> ·
|
|
24
25
|
<a href="#常见问题">常见问题</a>
|
|
@@ -26,7 +27,7 @@
|
|
|
26
27
|
|
|
27
28
|
---
|
|
28
29
|
|
|
29
|
-
|
|
30
|
+
微信和 Discord 都只让发 **20 MB**,邮件也大多卡在 **20 MB**。
|
|
30
31
|
于是你打开剪辑软件,猜一个码率,导出,看一眼大小,再导一次——最后还是不知道画质到底掉了多少。
|
|
31
32
|
|
|
32
33
|
`halfsize` 把这件事变成一行命令:
|
|
@@ -39,7 +40,7 @@ npx halfsize 婚礼.mp4 --to 25mb
|
|
|
39
40
|
画质到底还在不在。
|
|
40
41
|
|
|
41
42
|
```
|
|
42
|
-
halfsize 0.1.
|
|
43
|
+
halfsize 0.1.5
|
|
43
44
|
|
|
44
45
|
source.mp4
|
|
45
46
|
23.2 MB 8s 1920x1080 30.00 fps h264
|
|
@@ -50,9 +51,9 @@ npx halfsize 婚礼.mp4 --to 25mb
|
|
|
50
51
|
estimate 1.94 MB (12x smaller)
|
|
51
52
|
output source.2mb.mp4
|
|
52
53
|
|
|
53
|
-
measuring quality done in
|
|
54
|
+
measuring quality done in 1s
|
|
54
55
|
|
|
55
|
-
23.2 MB -> 1.99 MB 12x smaller 0.
|
|
56
|
+
23.2 MB -> 1.99 MB 12x smaller 0.4% under target
|
|
56
57
|
VMAF 84.4 SSIM 0.968 slight softening
|
|
57
58
|
|
|
58
59
|
-> C:\Videos\source.2mb.mp4
|
|
@@ -60,7 +61,7 @@ npx halfsize 婚礼.mp4 --to 25mb
|
|
|
60
61
|
|
|
61
62
|
## 为什么做这个
|
|
62
63
|
|
|
63
|
-
「把视频变小」早就有人解决了。真正没人做好的是:**「刚好塞进
|
|
64
|
+
「把视频变小」早就有人解决了。真正没人做好的是:**「刚好塞进 20 MB,然后告诉我画质还行不行」**。
|
|
64
65
|
|
|
65
66
|
- HandBrake 很好用,但它要你想清楚编码器、RF 值、preset 这些词。
|
|
66
67
|
- 在线压缩网站也能用,直到你想起自己刚把私人视频传给了陌生人。
|
|
@@ -84,6 +85,12 @@ npx halfsize 婚礼.mp4 --to 25mb
|
|
|
84
85
|
# 压到指定大小
|
|
85
86
|
npx halfsize video.mp4 --to 25mb
|
|
86
87
|
|
|
88
|
+
# 或者直接说你要传到哪里
|
|
89
|
+
npx halfsize video.mp4 --for discord
|
|
90
|
+
|
|
91
|
+
# 一次压好几个
|
|
92
|
+
npx halfsize clip1.mp4 clip2.mp4 clip3.mp4 --to 10mb
|
|
93
|
+
|
|
87
94
|
# 更小的文件,H.265
|
|
88
95
|
npx halfsize video.mp4 --to 10mb --codec h265
|
|
89
96
|
|
|
@@ -109,7 +116,7 @@ npm install -g halfsize
|
|
|
109
116
|
npx halfsize --web
|
|
110
117
|
```
|
|
111
118
|
|
|
112
|
-
打开 `http://127.0.0.1:4174`,把视频拖进去,选
|
|
119
|
+
打开 `http://127.0.0.1:4174`,把视频拖进去,选 20 MB / 16 MB / 5 MB,或者自己输入一个数,
|
|
113
120
|
然后看着它压。文件只会写进**你自己电脑**的临时目录,不会发到任何地方。
|
|
114
121
|
|
|
115
122
|
<p align="center">
|
|
@@ -118,6 +125,18 @@ npx halfsize --web
|
|
|
118
125
|
|
|
119
126
|
每次压完可以生成一张**分享图**(就是本文最上面那张),上面是这次真实的数字。
|
|
120
127
|
|
|
128
|
+
附实测画质的分平台教程:
|
|
129
|
+
[微信 20 MB](https://YKLovePython.github.io/halfsize/zh/wechat/) ·
|
|
130
|
+
[任意 20 MB 限制](https://YKLovePython.github.io/halfsize/zh/20mb/) ·
|
|
131
|
+
[Discord 20 MB](https://YKLovePython.github.io/halfsize/discord/) ·
|
|
132
|
+
[WhatsApp 16 MB](https://YKLovePython.github.io/halfsize/whatsapp/)。
|
|
133
|
+
|
|
134
|
+
### 给 agent 用
|
|
135
|
+
|
|
136
|
+
仓库里带了 [`skills/halfsize/SKILL.md`](skills/halfsize/SKILL.md)。把整个文件夹放进你的
|
|
137
|
+
agent 技能目录(Claude Code 是 `.claude/skills/halfsize/`),agent 就能把「把这个视频压到
|
|
138
|
+
25 MB」变成一次压缩加一份画质报告,而不是瞎猜码率。
|
|
139
|
+
|
|
121
140
|
## 它怎么工作
|
|
122
141
|
|
|
123
142
|
1. **探测。** 读取时长、分辨率、帧率、音频码率、旋转信息、HDR 标记。
|
|
@@ -140,16 +159,35 @@ VMAF ≥ 70 能看出损失
|
|
|
140
159
|
VMAF 是 Netflix 提出的感知质量指标,比 PSNR 或「码率多少」更接近人眼判断。
|
|
141
160
|
它不是魔法,但比「相信我」靠谱得多。
|
|
142
161
|
|
|
162
|
+
## 各档大小到底损失多少画质
|
|
163
|
+
|
|
164
|
+
同一个 60 秒 1080p 视频,默认设置,在普通笔记本上实测:
|
|
165
|
+
|
|
166
|
+
| 目标 | 输出 | 分辨率 | 视频码率 | VMAF | 结论 |
|
|
167
|
+
|---|---|---|---|---|---|
|
|
168
|
+
| 20 MB | 19.45 MB | 1280×720 | 2.46 Mbps | 88.3 | 轻微变软 |
|
|
169
|
+
| 16 MB | 15.59 MB | 960×540 | 1.94 Mbps | 84.8 | 轻微变软 |
|
|
170
|
+
| 10 MB | 9.77 MB | 960×540 | 1.17 Mbps | 82.9 | 轻微变软 |
|
|
171
|
+
| 5 MB | 4.93 MB | 640×360 | 0.55 Mbps | 72.0 | 能看出损失 |
|
|
172
|
+
|
|
173
|
+
源片:60 秒、1920×1080、30fps、18 Mbps H.264(136 MB)。VMAF 在中间点对着源片测量,
|
|
174
|
+
SSIM 取三个采样点。实际数字会随内容变化:新闻口播会好很多,满屏彩纸会差很多。
|
|
175
|
+
|
|
176
|
+
20 MB 这一档是故意降到 720p 的:60 秒 1080p 给 2.4 Mbps,还不如把同样的比特给 720p。
|
|
177
|
+
想强制保分辨率就加 `--no-downscale`;播放器支持的话,`--codec h265` 能在同尺寸下多换约 30% 画质。
|
|
178
|
+
|
|
143
179
|
## 命令行参数
|
|
144
180
|
|
|
145
181
|
```
|
|
146
182
|
halfsize <file> [options]
|
|
147
183
|
|
|
148
184
|
--to <size> 目标大小:25mb、700k、1.5gb
|
|
185
|
+
--for <app> 按平台上限:discord、whatsapp、wechat、email、twitter
|
|
149
186
|
--crf <n> 质量模式,不按大小(18 最好,23 均衡,28 更小)
|
|
150
187
|
--codec <name> h264(默认,兼容性最好)或 h265(大约再小 30%)
|
|
151
188
|
--preset <name> ultrafast .. veryslow(默认 medium)
|
|
152
189
|
--out <file> 输出路径
|
|
190
|
+
--no-audio 去掉音轨
|
|
153
191
|
--no-verify 跳过 SSIM/VMAF 检测
|
|
154
192
|
--no-downscale 绝不降分辨率(可能会压不到目标)
|
|
155
193
|
--json 机器可读结果
|
|
@@ -163,6 +201,9 @@ halfsize <file> [options]
|
|
|
163
201
|
|
|
164
202
|
大小支持 `b`、`k`/`kb`、`m`/`mb`、`g`/`gb`,中间可以有空格。不写单位按 MB 算。
|
|
165
203
|
|
|
204
|
+
`--for` 用的是当前免费档上限(2026 年 11 月核对):`discord` 20 MB、`whatsapp` 16 MB、
|
|
205
|
+
`wechat` 20 MB、`email` 20 MB、`twitter` 512 MB。平台上限会变,写死不如随时用 `--to` 覆盖。
|
|
206
|
+
|
|
166
207
|
退出码:`0` 成功,`1` 出错。`--json` 会返回方案、画质测量和目标误差,方便包进脚本。
|
|
167
208
|
|
|
168
209
|
## 安装说明
|
|
@@ -177,18 +218,39 @@ HALFSIZE_FFMPEG=/path/to/ffmpeg HALFSIZE_FFPROBE=/path/to/ffprobe npx halfsize v
|
|
|
177
218
|
Windows 上 `winget install Gyan.FFmpeg` 就够了;macOS 用 `brew install ffmpeg`;
|
|
178
219
|
Debian/Ubuntu 用 `sudo apt install ffmpeg`。跑一下 `npx halfsize --doctor` 能看到实际用的是哪个。
|
|
179
220
|
|
|
221
|
+
如果 GitHub 下载慢或被阻断(大陆网络很常见),可以让 `ffmpeg-static` 改从 npmmirror 取二进制:
|
|
222
|
+
|
|
223
|
+
```bash
|
|
224
|
+
# macOS / Linux
|
|
225
|
+
FFMPEG_BINARIES_URL=https://registry.npmmirror.com/-/binary/ffmpeg-static npx halfsize 视频.mp4 --to 25mb
|
|
226
|
+
|
|
227
|
+
# Windows PowerShell
|
|
228
|
+
$env:FFMPEG_BINARIES_URL="https://registry.npmmirror.com/-/binary/ffmpeg-static"; npx halfsize 视频.mp4 --to 25mb
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
`ffprobe` 是随包附带的,不需要单独下载。已经装过系统 FFmpeg 的话,`halfsize` 会优先用它,
|
|
232
|
+
连这一步都可以跳过。
|
|
233
|
+
|
|
180
234
|
## 常见问题
|
|
181
235
|
|
|
182
236
|
**视频会被上传吗?**
|
|
183
237
|
不会。命令行完全不联网。网页版只和 `127.0.0.1` 通信,上传的文件放在临时目录,进程退出就删掉。
|
|
184
238
|
|
|
239
|
+
**Discord 发不出去,怎么压到 20 MB?**
|
|
240
|
+
`npx halfsize 视频.mp4 --for discord` 会按免费档上限(2026 年底是 20 MB)压,输出就是普通 MP4,
|
|
241
|
+
Discord 预览能直接播。长一点的 1080p 视频通常会降到 720p,尺寸就是靠这一步省出来的。
|
|
242
|
+
|
|
243
|
+
**微信提示视频太大怎么办?**
|
|
244
|
+
`npx halfsize 视频.mp4 --for wechat` 会按微信聊天视频的 20 MB 上限压。还想更小就换成
|
|
245
|
+
`--to 10mb`(或者任意大小)。
|
|
246
|
+
|
|
185
247
|
**为什么给我降分辨率了?**
|
|
186
|
-
因为
|
|
248
|
+
因为 20 MB 的 4K 根本不够比特,硬撑 4K 只会满屏马赛克。降分辨率、把每像素比特数保住,
|
|
187
249
|
通常比「分辨率不变但饿死编码器」好看得多。不想降就加 `--no-downscale`。
|
|
188
250
|
|
|
189
251
|
**为什么输出比目标小一点,而不是刚好等于?**
|
|
190
252
|
容器开销和两遍 ABR 都会有波动。`halfsize` 会刻意瞄准低 3%,保证不会超过平台的硬上限。
|
|
191
|
-
上面那个例子里,最终比目标低 0.
|
|
253
|
+
上面那个例子里,最终比目标低 0.4%。
|
|
192
254
|
|
|
193
255
|
**H.264 还是 H.265?**
|
|
194
256
|
H.264 到处都能播,包括老安卓机和浏览器。H.265 同画质大约小 30%,但部分剪辑软件和老硬件
|
|
@@ -198,24 +260,26 @@ H.264 到处都能播,包括老安卓机和浏览器。H.265 同画质大约
|
|
|
198
260
|
能。就是 Node.js + FFmpeg,没有平台特定的东西。
|
|
199
261
|
|
|
200
262
|
**能批量压一个文件夹吗?**
|
|
201
|
-
|
|
263
|
+
能。直接把文件列出来,或者让 shell 展开:
|
|
202
264
|
|
|
203
265
|
```bash
|
|
204
|
-
|
|
266
|
+
npx halfsize *.mp4 --to 25mb
|
|
205
267
|
```
|
|
206
268
|
|
|
269
|
+
每个文件单独出方案和结论,某个失败不影响后面的,`--json` 返回 `{ "files": [ ... ] }`。
|
|
270
|
+
多个输入时不能用 `--out`。
|
|
271
|
+
|
|
207
272
|
**为什么叫 halfsize?**
|
|
208
273
|
因为「能小多少」的诚实答案通常是「差不多一半,或者远不止一半」。它每次都会把真实数字告诉你。
|
|
209
274
|
|
|
210
275
|
## 路线图
|
|
211
276
|
|
|
212
277
|
- 硬件编码(NVENC / QSV / VideoToolbox)作为可选的快速路径
|
|
213
|
-
-
|
|
278
|
+
- 文件夹监听,新文件一出现就自动压
|
|
214
279
|
- 字幕和多音轨原样保留
|
|
215
280
|
- HDR 转 SDR(让 iPhone 拍的 HDR 视频保住颜色)
|
|
216
281
|
- 源已经是 AV1 时改用 AV1(`libsvtav1`)
|
|
217
282
|
- 单文件二进制,不依赖 Node.js
|
|
218
|
-
- 面向具体平台上限的预设(微信、Discord、邮件、X、Bluesky)
|
|
219
283
|
|
|
220
284
|
想要哪个就开 issue 说一声。欢迎 PR,见 [CONTRIBUTING.md](CONTRIBUTING.md)。
|
|
221
285
|
|
|
@@ -236,6 +300,8 @@ node bin/halfsize.mjs --web
|
|
|
236
300
|
|
|
237
301
|
MIT。随便用,随便改,随便发布。
|
|
238
302
|
|
|
303
|
+
版本变化见 [CHANGELOG.md](CHANGELOG.md)。
|
|
304
|
+
|
|
239
305
|
---
|
|
240
306
|
|
|
241
307
|
如果 `halfsize` 帮你少猜了一次码率,点个 star 能让下一个人也找到它。
|
package/package.json
CHANGED
|
@@ -1,47 +1,59 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "halfsize",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Compress any video to an exact
|
|
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.
|
|
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
|
-
"
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
"
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
"video
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
-
|
|
47
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "halfsize",
|
|
3
|
+
"version": "0.1.5",
|
|
4
|
+
"description": "Compress any video to an exact size (Discord or WeChat 20 MB, WhatsApp 16 MB) locally with FFmpeg and verify the quality with VMAF/SSIM.",
|
|
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://YKLovePython.github.io/halfsize/",
|
|
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
|
+
"README.zh-CN.md",
|
|
26
|
+
"CHANGELOG.md",
|
|
27
|
+
"LICENSE"
|
|
28
|
+
],
|
|
29
|
+
"scripts": {
|
|
30
|
+
"start": "node bin/halfsize.mjs",
|
|
31
|
+
"web": "node bin/halfsize.mjs --web",
|
|
32
|
+
"test": "node --test test/human.test.mjs test/plan.test.mjs test/quality.test.mjs test/cli.test.mjs test/e2e.test.mjs"
|
|
33
|
+
},
|
|
34
|
+
"keywords": [
|
|
35
|
+
"video",
|
|
36
|
+
"compress",
|
|
37
|
+
"compress-video",
|
|
38
|
+
"video-compression",
|
|
39
|
+
"video-compressor",
|
|
40
|
+
"reduce-video-size",
|
|
41
|
+
"ffmpeg",
|
|
42
|
+
"target-size",
|
|
43
|
+
"vmaf",
|
|
44
|
+
"ssim",
|
|
45
|
+
"discord",
|
|
46
|
+
"wechat",
|
|
47
|
+
"whatsapp",
|
|
48
|
+
"cli",
|
|
49
|
+
"privacy",
|
|
50
|
+
"windows",
|
|
51
|
+
"macos",
|
|
52
|
+
"linux"
|
|
53
|
+
],
|
|
54
|
+
"license": "MIT",
|
|
55
|
+
"dependencies": {
|
|
56
|
+
"ffmpeg-static": "^5.3.0",
|
|
57
|
+
"ffprobe-static": "^3.1.0"
|
|
58
|
+
}
|
|
59
|
+
}
|
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.5";
|
|
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,27 @@ 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 (checked November 2026). Discord raised
|
|
50
|
+
// its free cap to 20 MB in August 2026 and WeChat's chat-video cap is 20 MB.
|
|
51
|
+
// Caps change, so --to always wins if a preset is wrong for your account tier.
|
|
52
|
+
export const PLATFORMS = {
|
|
53
|
+
discord: { bytes: 20 * 1_000_000, label: "Discord free (20 MB)" },
|
|
54
|
+
whatsapp: { bytes: 16 * 1_000_000, label: "WhatsApp media (16 MB)" },
|
|
55
|
+
wechat: { bytes: 20 * 1_000_000, label: "WeChat video (20 MB)" },
|
|
56
|
+
email: { bytes: 20 * 1_000_000, label: "Email attachment (20 MB)" },
|
|
57
|
+
twitter: { bytes: 512 * 1_000_000, label: "X / Twitter (512 MB)" },
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
function platformFor(name) {
|
|
61
|
+
const key = String(name || "").toLowerCase();
|
|
62
|
+
const found = PLATFORMS[key];
|
|
63
|
+
if (!found) {
|
|
64
|
+
throw new Error(`Unknown platform "${name}". Use one of: ${Object.keys(PLATFORMS).join(", ")}.`);
|
|
65
|
+
}
|
|
66
|
+
return { key, ...found };
|
|
67
|
+
}
|
|
44
68
|
|
|
45
69
|
function parseArgs(argv) {
|
|
46
70
|
const args = { _: [], flags: { ...DEFAULTS } };
|
|
@@ -52,10 +76,12 @@ function parseArgs(argv) {
|
|
|
52
76
|
else if (a === "--dry-run") args.flags.dryRun = true;
|
|
53
77
|
else if (a === "--no-verify") args.flags.verify = false;
|
|
54
78
|
else if (a === "--no-downscale") args.flags.downscale = false;
|
|
79
|
+
else if (a === "--no-audio") args.flags.audio = false;
|
|
55
80
|
else if (a === "--web") args.flags.web = true;
|
|
56
81
|
else if (a === "--doctor") args.flags.doctor = true;
|
|
57
82
|
else if (a === "--port") args.flags.port = Number(argv[++i]);
|
|
58
83
|
else if (a === "--to") args.flags.to = argv[++i];
|
|
84
|
+
else if (a === "--for") args.flags.for = argv[++i];
|
|
59
85
|
else if (a === "--crf") args.flags.crf = Number(argv[++i]);
|
|
60
86
|
else if (a === "--codec") args.flags.codec = argv[++i];
|
|
61
87
|
else if (a === "--preset") args.flags.preset = argv[++i];
|
|
@@ -81,18 +107,53 @@ export async function main(argv) {
|
|
|
81
107
|
if (flags.doctor) return doctor();
|
|
82
108
|
if (flags.web) return startWeb(flags.port);
|
|
83
109
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if (!flags.to && flags.crf === undefined) {
|
|
87
|
-
throw new Error("Pick a target: --to 25mb, or
|
|
110
|
+
if (flags.to && flags.for) throw new Error("Use --to or --for, not both.");
|
|
111
|
+
const platform = flags.for ? platformFor(flags.for) : null;
|
|
112
|
+
if (!flags.to && !platform && flags.crf === undefined) {
|
|
113
|
+
throw new Error("Pick a target: --to 25mb, --for discord, or --crf 23 for quality mode.");
|
|
114
|
+
}
|
|
115
|
+
if (flags.out && args._.length > 1) {
|
|
116
|
+
throw new Error("--out only works with one input file. It would overwrite itself in a batch.");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const inputs = args._.map((file) => path.resolve(file));
|
|
120
|
+
const targetBytes = platform ? platform.bytes : flags.to ? parseSize(flags.to) : null;
|
|
121
|
+
const batch = inputs.length > 1;
|
|
122
|
+
|
|
123
|
+
const results = [];
|
|
124
|
+
let failed = 0;
|
|
125
|
+
for (const input of inputs) {
|
|
126
|
+
try {
|
|
127
|
+
results.push(await processFile(input, flags, targetBytes, platform, batch));
|
|
128
|
+
} catch (err) {
|
|
129
|
+
failed++;
|
|
130
|
+
results.push({ input: { file: input }, error: err?.message || String(err) });
|
|
131
|
+
if (!flags.json) console.error(`\n x ${path.basename(input)}: ${err?.message || err}`);
|
|
132
|
+
if (process.env.HALFSIZE_DEBUG) console.error(err?.stack);
|
|
133
|
+
}
|
|
88
134
|
}
|
|
89
135
|
|
|
90
|
-
|
|
136
|
+
if (flags.json) {
|
|
137
|
+
console.log(JSON.stringify(batch ? { files: results } : results[0], null, 2));
|
|
138
|
+
} else if (batch && !flags.dryRun) {
|
|
139
|
+
printBatch(results);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (failed) process.exitCode = 1;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function processFile(input, flags, targetBytes, platform, batch) {
|
|
146
|
+
if (!fs.existsSync(input)) throw new Error(`File not found: ${input}`);
|
|
147
|
+
|
|
91
148
|
const info = await probe(input);
|
|
92
149
|
|
|
93
150
|
if (targetBytes && targetBytes >= info.sizeBytes) {
|
|
94
|
-
|
|
95
|
-
|
|
151
|
+
if (batch) {
|
|
152
|
+
console.log(`\n ${path.basename(input)} already fits (${bytes(info.sizeBytes)}), skipped.`);
|
|
153
|
+
} else {
|
|
154
|
+
console.log(`\n This file is already ${bytes(info.sizeBytes)}, smaller than the ${bytes(targetBytes)} target.\n`);
|
|
155
|
+
}
|
|
156
|
+
return { input: { file: input, bytes: info.sizeBytes }, targetBytes, skipped: "already-smaller" };
|
|
96
157
|
}
|
|
97
158
|
|
|
98
159
|
const plan = planEncode(info, {
|
|
@@ -101,17 +162,20 @@ export async function main(argv) {
|
|
|
101
162
|
codec: flags.codec,
|
|
102
163
|
preset: flags.preset,
|
|
103
164
|
downscale: flags.downscale,
|
|
165
|
+
audio: flags.audio,
|
|
104
166
|
});
|
|
105
167
|
|
|
106
|
-
const outPath = flags.out
|
|
107
|
-
? path.resolve(flags.out)
|
|
108
|
-
: defaultOutput(input, flags, plan);
|
|
168
|
+
const outPath = flags.out ? path.resolve(flags.out) : defaultOutput(input, flags, plan);
|
|
109
169
|
|
|
110
|
-
if (!flags.json) printPlan(info, plan, flags, outPath);
|
|
170
|
+
if (!flags.json) printPlan(info, plan, flags, outPath, platform, !batch);
|
|
111
171
|
|
|
112
172
|
if (flags.dryRun) {
|
|
113
|
-
|
|
114
|
-
|
|
173
|
+
return {
|
|
174
|
+
info,
|
|
175
|
+
plan,
|
|
176
|
+
out: outPath,
|
|
177
|
+
dryRun: true,
|
|
178
|
+
};
|
|
115
179
|
}
|
|
116
180
|
|
|
117
181
|
const progress = new Progress(Boolean(process.stdout.isTTY) && !flags.json, info.duration);
|
|
@@ -123,7 +187,7 @@ export async function main(argv) {
|
|
|
123
187
|
}
|
|
124
188
|
|
|
125
189
|
let quality = { ssim: null, vmaf: null };
|
|
126
|
-
if (flags.verify
|
|
190
|
+
if (flags.verify) {
|
|
127
191
|
if (!flags.json) process.stdout.write(` measuring quality `);
|
|
128
192
|
quality = await measureQuality(input, outPath, info.duration);
|
|
129
193
|
if (!flags.json) process.stdout.write(`\r${" ".repeat(40)}\r`);
|
|
@@ -142,19 +206,34 @@ export async function main(argv) {
|
|
|
142
206
|
elapsedMs: result.elapsedMs,
|
|
143
207
|
};
|
|
144
208
|
|
|
145
|
-
if (flags.json)
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
}
|
|
209
|
+
if (!flags.json) printResult(summary, outPath);
|
|
210
|
+
return summary;
|
|
211
|
+
}
|
|
149
212
|
|
|
150
|
-
|
|
213
|
+
function printBatch(results) {
|
|
214
|
+
const done = results.filter((r) => r.output);
|
|
215
|
+
const skipped = results.filter((r) => r.skipped);
|
|
216
|
+
const errors = results.filter((r) => r.error);
|
|
217
|
+
console.log("");
|
|
218
|
+
if (done.length) {
|
|
219
|
+
const before = done.reduce((sum, r) => sum + r.input.bytes, 0);
|
|
220
|
+
const after = done.reduce((sum, r) => sum + r.output.bytes, 0);
|
|
221
|
+
console.log(` ${done.length} compressed ${bytes(before)} -> ${bytes(after)} ${ratio(before, after)} smaller`);
|
|
222
|
+
}
|
|
223
|
+
if (skipped.length) console.log(` ${skipped.length} skipped (already under target)`);
|
|
224
|
+
if (errors.length) console.log(` ${errors.length} failed`);
|
|
225
|
+
console.log("");
|
|
151
226
|
}
|
|
152
227
|
|
|
153
228
|
function defaultOutput(input, flags, plan) {
|
|
154
229
|
const dir = path.dirname(input);
|
|
155
230
|
const ext = path.extname(input);
|
|
156
231
|
const base = path.basename(input, ext);
|
|
157
|
-
const tag = flags.
|
|
232
|
+
const tag = flags.for
|
|
233
|
+
? String(flags.for).toLowerCase()
|
|
234
|
+
: flags.to
|
|
235
|
+
? String(flags.to).replace(/\s+/g, "").toLowerCase()
|
|
236
|
+
: `crf${flags.crf}`;
|
|
158
237
|
return path.join(dir, `${base}.${tag}.mp4`);
|
|
159
238
|
}
|
|
160
239
|
|
|
@@ -162,16 +241,18 @@ function line(label, value) {
|
|
|
162
241
|
console.log(` ${label.padEnd(10)} ${value}`);
|
|
163
242
|
}
|
|
164
243
|
|
|
165
|
-
function printPlan(info, plan, flags, outPath) {
|
|
166
|
-
console.log("");
|
|
167
|
-
console.log(` halfsize ${VERSION}`);
|
|
244
|
+
function printPlan(info, plan, flags, outPath, platform, showHeader = true) {
|
|
168
245
|
console.log("");
|
|
246
|
+
if (showHeader) {
|
|
247
|
+
console.log(` halfsize ${VERSION}`);
|
|
248
|
+
console.log("");
|
|
249
|
+
}
|
|
169
250
|
console.log(` ${path.basename(info.file)}`);
|
|
170
251
|
console.log(
|
|
171
252
|
` ${bytes(info.sizeBytes)} ${duration(info.duration)} ${info.video.width}x${info.video.height} ${info.video.fps.toFixed(2)} fps ${info.video.codec}`,
|
|
172
253
|
);
|
|
173
254
|
console.log("");
|
|
174
|
-
if (plan.mode === "size") line("target", bytes(plan.targetBytes));
|
|
255
|
+
if (plan.mode === "size") line("target", platform ? `${bytes(plan.targetBytes)} (${platform.label})` : bytes(plan.targetBytes));
|
|
175
256
|
else line("mode", `CRF ${plan.crf}`);
|
|
176
257
|
line(
|
|
177
258
|
"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,
|
package/web/index.html
CHANGED
|
@@ -138,6 +138,8 @@
|
|
|
138
138
|
.error.on { display: block; }
|
|
139
139
|
|
|
140
140
|
footer { margin-top: 46px; color: var(--dim); font-size: 12px; display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
|
141
|
+
footer a { color: var(--muted); }
|
|
142
|
+
footer a:hover { color: var(--accent); }
|
|
141
143
|
.kbd { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; color: var(--muted); background: var(--panel-2); border: 1px solid var(--line); padding: 3px 7px; border-radius: 6px; }
|
|
142
144
|
.hidden { display: none !important; }
|
|
143
145
|
</style>
|
|
@@ -150,7 +152,7 @@
|
|
|
150
152
|
</header>
|
|
151
153
|
|
|
152
154
|
<h1 data-i18n-html="title">把任何视频,塞进任何限制。<br><em>并且证明画质没变。</em></h1>
|
|
153
|
-
<p class="sub" data-i18n-html="sub"
|
|
155
|
+
<p class="sub" data-i18n-html="sub">微信和 Discord 都只让发 20 MB,邮件也差不多。选一个数字,剩下的交给它:算码率、降分辨率、两遍编码,最后用 <b>VMAF</b> 告诉你画质掉没掉。</p>
|
|
154
156
|
|
|
155
157
|
<div class="card" id="card">
|
|
156
158
|
<div id="drop" class="drop" role="button" tabindex="0">
|
|
@@ -172,9 +174,8 @@
|
|
|
172
174
|
<div>
|
|
173
175
|
<div class="label" data-i18n="target">目标大小</div>
|
|
174
176
|
<div class="chips" id="presets">
|
|
175
|
-
<button class="chip on" data-size="
|
|
176
|
-
<button class="chip" data-size="
|
|
177
|
-
<button class="chip" data-size="20mb">20 MB<small data-i18n="email">邮件</small></button>
|
|
177
|
+
<button class="chip on" data-size="20mb">20 MB<small data-i18n="wechat">微信 / Discord</small></button>
|
|
178
|
+
<button class="chip" data-size="16mb">16 MB<small>WhatsApp</small></button>
|
|
178
179
|
<button class="chip" data-size="5mb">5 MB<small data-i18n="web">网页</small></button>
|
|
179
180
|
</div>
|
|
180
181
|
<div class="row">
|
|
@@ -217,7 +218,7 @@
|
|
|
217
218
|
|
|
218
219
|
<footer>
|
|
219
220
|
<div><span class="kbd">npx halfsize video.mp4 --to 25mb</span></div>
|
|
220
|
-
<div data-i18n="footer">本地 ffmpeg · 零上传 · MIT</div>
|
|
221
|
+
<div><span data-i18n="footer">本地 ffmpeg · 零上传 · MIT</span> · <a href="https://github.com/YKLovePython/halfsize" target="_blank" rel="noopener">GitHub ★</a> · <a href="https://YKLovePython.github.io/halfsize/" target="_blank" rel="noopener">Guides</a></div>
|
|
221
222
|
</footer>
|
|
222
223
|
</div>
|
|
223
224
|
|
|
@@ -226,7 +227,7 @@ const I18N = {
|
|
|
226
227
|
zh: {
|
|
227
228
|
pill: "本地运行 · 不上传 · 无账号",
|
|
228
229
|
title: "把任何视频,塞进任何限制。<br><em>并且证明画质没变。</em>",
|
|
229
|
-
sub: "
|
|
230
|
+
sub: "微信和 Discord 都只让发 20 MB,邮件也差不多。选一个数字,剩下的交给它:算码率、降分辨率、两遍编码,最后用 <b>VMAF</b> 告诉你画质掉没掉。",
|
|
230
231
|
dropBig: "把视频拖进来,或点击选择",
|
|
231
232
|
dropSmall: "MP4 · MOV · MKV · AVI · WebM · 不超过 8 GB",
|
|
232
233
|
target: "目标大小",
|
|
@@ -242,8 +243,7 @@ const I18N = {
|
|
|
242
243
|
verifying: "正在测画质…",
|
|
243
244
|
done: "完成",
|
|
244
245
|
uploading: "上传中",
|
|
245
|
-
wechat: "微信",
|
|
246
|
-
email: "邮件",
|
|
246
|
+
wechat: "微信 / Discord",
|
|
247
247
|
web: "网页",
|
|
248
248
|
smallerLabel: "更小",
|
|
249
249
|
accuracyUnder: (p) => `比目标小 ${p}%`,
|
|
@@ -266,7 +266,7 @@ const I18N = {
|
|
|
266
266
|
en: {
|
|
267
267
|
pill: "runs locally · no upload · no account",
|
|
268
268
|
title: "Fit any video into any limit.<br><em>And prove nothing changed.</em>",
|
|
269
|
-
sub: "WeChat
|
|
269
|
+
sub: "WeChat and Discord both cap videos at 20 MB. Email is about the same. Pick the number. It does the rest: bitrate math, resolution, two-pass encoding, then measures the result with <b>VMAF</b>.",
|
|
270
270
|
dropBig: "Drop a video, or click to choose",
|
|
271
271
|
dropSmall: "MP4 · MOV · MKV · AVI · WebM · up to 8 GB",
|
|
272
272
|
target: "Target size",
|
|
@@ -282,8 +282,7 @@ const I18N = {
|
|
|
282
282
|
verifying: "measuring quality…",
|
|
283
283
|
done: "done",
|
|
284
284
|
uploading: "uploading",
|
|
285
|
-
wechat: "WeChat",
|
|
286
|
-
email: "email",
|
|
285
|
+
wechat: "WeChat / Discord",
|
|
287
286
|
web: "web",
|
|
288
287
|
smallerLabel: "smaller",
|
|
289
288
|
accuracyUnder: (p) => `${p}% under target`,
|
|
@@ -320,7 +319,7 @@ const drop = $("drop"), fileInput = $("file"), editor = $("editor"), preview = $
|
|
|
320
319
|
const meta = $("meta"), fname = $("fname"), go = $("go"), progress = $("progress");
|
|
321
320
|
const fill = $("fill"), pct = $("pct"), speed = $("speed"), result = $("result"), error = $("error");
|
|
322
321
|
|
|
323
|
-
let state = { id: null, target: "
|
|
322
|
+
let state = { id: null, target: "20mb", codec: "h264", data: null };
|
|
324
323
|
|
|
325
324
|
function fmtBytes(n) {
|
|
326
325
|
if (!n && n !== 0) return "—";
|