halfsize 0.1.2 → 0.1.4

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