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 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 &amp; 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 refuses anything over **25 MB**. Discord gives you **10 MB**. Email chokes around **20 MB**.
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.0
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 2s
55
+ measuring quality done in 1s
55
56
 
56
- 23.2 MB -> 1.99 MB 12x smaller 0.3% under target
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 25 MB, and tell me if it
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 25 MB / 10 MB / 20 MB / 5 MB or type
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` and `ffprobe-static` download prebuilt binaries during `npm install`. If you are
181
- behind a proxy or a TLS-inspecting network, that download can fail. `halfsize` then falls back
182
- to any `ffmpeg` on your `PATH`, or to a build you point at explicitly:
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 25 MB of 4K is not enough bits to look good. Lowering resolution and keeping the
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.3% under.
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
- Not built in yet. A shell loop works today:
272
+ Yes. List the files, or let your shell expand them:
215
273
 
216
274
  ```bash
217
- for f in *.mp4; do npx halfsize "$f" --to 25mb; done
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
- - Batch mode and a folder watcher
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
- 微信不让发超过 **25 MB**,Discord 只给 **10 MB**,邮件大多卡在 **20 MB**。
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.0
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 2s
54
+ measuring quality done in 1s
54
55
 
55
- 23.2 MB -> 1.99 MB 12x smaller 0.3% under target
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
- 「把视频变小」早就有人解决了。真正没人做好的是:**「刚好塞进 25 MB,然后告诉我画质还行不行」**。
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`,把视频拖进去,选 25 MB / 10 MB / 20 MB / 5 MB,或者自己输入一个数,
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
- 因为 25 MB 的 4K 根本不够比特,硬撑 4K 只会满屏马赛克。降分辨率、把每像素比特数保住,
248
+ 因为 20 MB 的 4K 根本不够比特,硬撑 4K 只会满屏马赛克。降分辨率、把每像素比特数保住,
187
249
  通常比「分辨率不变但饿死编码器」好看得多。不想降就加 `--no-downscale`。
188
250
 
189
251
  **为什么输出比目标小一点,而不是刚好等于?**
190
252
  容器开销和两遍 ABR 都会有波动。`halfsize` 会刻意瞄准低 3%,保证不会超过平台的硬上限。
191
- 上面那个例子里,最终比目标低 0.3%。
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
- 还没内置。现在可以用一行 shell:
263
+ 能。直接把文件列出来,或者让 shell 展开:
202
264
 
203
265
  ```bash
204
- for f in *.mp4; do npx halfsize "$f" --to 25mb; done
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.3",
4
- "description": "Compress any video to an exact target size, locally, and prove the quality did not drop.",
5
- "type": "module",
6
- "bin": {
7
- "halfsize": "bin/halfsize.mjs"
8
- },
9
- "repository": {
10
- "type": "git",
11
- "url": "git+https://github.com/YKLovePython/halfsize.git"
12
- },
13
- "homepage": "https://github.com/YKLovePython/halfsize#readme",
14
- "bugs": {
15
- "url": "https://github.com/YKLovePython/halfsize/issues"
16
- },
17
- "engines": {
18
- "node": ">=18"
19
- },
20
- "files": [
21
- "bin",
22
- "src",
23
- "web",
24
- "README.md",
25
- "LICENSE"
26
- ],
27
- "scripts": {
28
- "start": "node bin/halfsize.mjs",
29
- "web": "node bin/halfsize.mjs --web",
30
- "test": "node --test test/human.test.mjs test/plan.test.mjs test/quality.test.mjs test/cli.test.mjs test/e2e.test.mjs"
31
- },
32
- "keywords": [
33
- "video",
34
- "compress",
35
- "video-compression",
36
- "ffmpeg",
37
- "target-size",
38
- "vmaf",
39
- "cli",
40
- "windows"
41
- ],
42
- "license": "MIT",
43
- "dependencies": {
44
- "ffmpeg-static": "^5.3.0",
45
- "ffprobe-static": "^3.1.0"
46
- }
47
- }
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.3";
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
- const input = path.resolve(args._[0]);
85
- if (!fs.existsSync(input)) throw new Error(`File not found: ${input}`);
86
- if (!flags.to && flags.crf === undefined) {
87
- throw new Error("Pick a target: --to 25mb, or use --crf 23 for quality mode.");
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
- const targetBytes = flags.to ? parseSize(flags.to) : null;
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
- console.log(`\n This file is already ${bytes(info.sizeBytes)}, smaller than the ${bytes(targetBytes)} target.\n`);
95
- return;
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
- if (flags.json) console.log(JSON.stringify({ info, plan, out: outPath }, null, 2));
114
- return;
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 && !flags.noVerify) {
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
- console.log(JSON.stringify(summary, null, 2));
147
- return;
148
- }
209
+ if (!flags.json) printResult(summary, outPath);
210
+ return summary;
211
+ }
149
212
 
150
- printResult(summary, outPath);
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.to ? String(flags.to).replace(/\s+/g, "").toLowerCase() : `crf${flags.crf}`;
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", "2"];
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">微信只让发 25 MB,Discord 只让发 10 MB。选一个数字,剩下的交给它:算码率、降分辨率、两遍编码,最后用 <b>VMAF</b> 告诉你画质掉没掉。</p>
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="25mb">25 MB<small data-i18n="wechat">微信</small></button>
176
- <button class="chip" data-size="10mb">10 MB<small>Discord</small></button>
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: "微信只让发 25 MB,Discord 只让发 10 MB。选一个数字,剩下的交给它:算码率、降分辨率、两遍编码,最后用 <b>VMAF</b> 告诉你画质掉没掉。",
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 caps at 25 MB. Discord caps at 10 MB. Pick the number. It does the rest: bitrate math, resolution, two-pass encoding, then measures the result with <b>VMAF</b>.",
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: "25mb", codec: "h264", data: null };
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 "—";