halfsize 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md 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,10 +168,12 @@ or bitrate. It is not magic, but it is a much better answer than "trust me".
155
168
  halfsize <file> [options]
156
169
 
157
170
  --to <size> Target size: 25mb, 700k, 1.5gb
171
+ --for <app> Target cap for discord, whatsapp, wechat, email, twitter
158
172
  --crf <n> Quality mode instead of size mode (18 best, 23 balanced, 28 small)
159
173
  --codec <name> h264 (default, maximum compatibility) or h265 (about 30% smaller)
160
174
  --preset <name> ultrafast .. veryslow (default: medium)
161
175
  --out <file> Output path
176
+ --no-audio Drop the audio track
162
177
  --no-verify Skip SSIM/VMAF
163
178
  --no-downscale Never reduce resolution (the target may then be missed)
164
179
  --json Machine-readable result
@@ -172,6 +187,9 @@ halfsize <file> [options]
172
187
 
173
188
  Sizes accept `b`, `k`/`kb`, `m`/`mb`, `g`/`gb`, with or without a space. No unit means MB.
174
189
 
190
+ `--for` uses the current free-tier caps: `discord` 10 MB, `whatsapp` 16 MB, `wechat` 25 MB,
191
+ `email` 20 MB, `twitter` 512 MB. Caps change, so `--to` always overrides.
192
+
175
193
  Exit codes: `0` success, `1` error. `--json` returns the plan, the measured quality, and the
176
194
  target accuracy, which makes it easy to wrap in a script.
177
195
 
@@ -211,12 +229,15 @@ at the same quality but can be awkward in some editors and older hardware. Defau
211
229
  Yes. It is plain Node.js and FFmpeg.
212
230
 
213
231
  **Can I batch a folder?**
214
- Not built in yet. A shell loop works today:
232
+ Yes. List the files, or let your shell expand them:
215
233
 
216
234
  ```bash
217
- for f in *.mp4; do npx halfsize "$f" --to 25mb; done
235
+ npx halfsize *.mp4 --to 25mb
218
236
  ```
219
237
 
238
+ Each file gets its own plan and verdict, a failure does not stop the rest, and `--json` returns
239
+ `{ "files": [ ... ] }`. `--out` is allowed only when there is a single input.
240
+
220
241
  **Why "halfsize"?**
221
242
  Because the honest answer to "how much smaller will it get?" is usually "about half, or a lot
222
243
  more than half". The tool reports the real number either way.
@@ -224,12 +245,11 @@ more than half". The tool reports the real number either way.
224
245
  ## Roadmap
225
246
 
226
247
  - Hardware encoding (NVENC / QSV / VideoToolbox) as an opt-in fast path
227
- - Batch mode and a folder watcher
248
+ - A folder watcher that compresses new files as they appear
228
249
  - Subtitle and multiple audio track passthrough
229
250
  - HDR-to-SDR tonemapping, so iPhone HDR clips keep their colors
230
251
  - AV1 (`libsvtav1`) when the source is already AV1-friendly
231
252
  - A single-file binary, so Node.js is not required
232
- - Presets for specific platform caps (WeChat, Discord, email, X, Bluesky)
233
253
 
234
254
  If you want one of these, open an issue and say which. Pull requests are welcome; see
235
255
  [CONTRIBUTING.md](CONTRIBUTING.md).
@@ -251,6 +271,8 @@ it to a target, probes the output, and asserts the size and SSIM. No fixtures in
251
271
 
252
272
  MIT. Use it, fork it, ship it.
253
273
 
274
+ See [CHANGELOG.md](CHANGELOG.md) for what changed.
275
+
254
276
  ---
255
277
 
256
278
  If `halfsize` saved you from guessing a bitrate, a star helps the next person find it.
package/README.zh-CN.md CHANGED
@@ -39,7 +39,7 @@ npx halfsize 婚礼.mp4 --to 25mb
39
39
  画质到底还在不在。
40
40
 
41
41
  ```
42
- halfsize 0.1.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,10 +158,12 @@ VMAF 是 Netflix 提出的感知质量指标,比 PSNR 或「码率多少」更
146
158
  halfsize <file> [options]
147
159
 
148
160
  --to <size> 目标大小:25mb、700k、1.5gb
161
+ --for <app> 按平台上限:discord、whatsapp、wechat、email、twitter
149
162
  --crf <n> 质量模式,不按大小(18 最好,23 均衡,28 更小)
150
163
  --codec <name> h264(默认,兼容性最好)或 h265(大约再小 30%)
151
164
  --preset <name> ultrafast .. veryslow(默认 medium)
152
165
  --out <file> 输出路径
166
+ --no-audio 去掉音轨
153
167
  --no-verify 跳过 SSIM/VMAF 检测
154
168
  --no-downscale 绝不降分辨率(可能会压不到目标)
155
169
  --json 机器可读结果
@@ -163,6 +177,9 @@ halfsize <file> [options]
163
177
 
164
178
  大小支持 `b`、`k`/`kb`、`m`/`mb`、`g`/`gb`,中间可以有空格。不写单位按 MB 算。
165
179
 
180
+ `--for` 用的是当前免费档上限:`discord` 10 MB、`whatsapp` 16 MB、`wechat` 25 MB、
181
+ `email` 20 MB、`twitter` 512 MB。平台上限会变,写死不如随时用 `--to` 覆盖。
182
+
166
183
  退出码:`0` 成功,`1` 出错。`--json` 会返回方案、画质测量和目标误差,方便包进脚本。
167
184
 
168
185
  ## 安装说明
@@ -198,24 +215,26 @@ H.264 到处都能播,包括老安卓机和浏览器。H.265 同画质大约
198
215
  能。就是 Node.js + FFmpeg,没有平台特定的东西。
199
216
 
200
217
  **能批量压一个文件夹吗?**
201
- 还没内置。现在可以用一行 shell:
218
+ 能。直接把文件列出来,或者让 shell 展开:
202
219
 
203
220
  ```bash
204
- for f in *.mp4; do npx halfsize "$f" --to 25mb; done
221
+ npx halfsize *.mp4 --to 25mb
205
222
  ```
206
223
 
224
+ 每个文件单独出方案和结论,某个失败不影响后面的,`--json` 返回 `{ "files": [ ... ] }`。
225
+ 多个输入时不能用 `--out`。
226
+
207
227
  **为什么叫 halfsize?**
208
228
  因为「能小多少」的诚实答案通常是「差不多一半,或者远不止一半」。它每次都会把真实数字告诉你。
209
229
 
210
230
  ## 路线图
211
231
 
212
232
  - 硬件编码(NVENC / QSV / VideoToolbox)作为可选的快速路径
213
- - 批量模式与文件夹监听
233
+ - 文件夹监听,新文件一出现就自动压
214
234
  - 字幕和多音轨原样保留
215
235
  - HDR 转 SDR(让 iPhone 拍的 HDR 视频保住颜色)
216
236
  - 源已经是 AV1 时改用 AV1(`libsvtav1`)
217
237
  - 单文件二进制,不依赖 Node.js
218
- - 面向具体平台上限的预设(微信、Discord、邮件、X、Bluesky)
219
238
 
220
239
  想要哪个就开 issue 说一声。欢迎 PR,见 [CONTRIBUTING.md](CONTRIBUTING.md)。
221
240
 
@@ -236,6 +255,8 @@ node bin/halfsize.mjs --web
236
255
 
237
256
  MIT。随便用,随便改,随便发布。
238
257
 
258
+ 版本变化见 [CHANGELOG.md](CHANGELOG.md)。
259
+
239
260
  ---
240
261
 
241
262
  如果 `halfsize` 帮你少猜了一次码率,点个 star 能让下一个人也找到它。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "halfsize",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Compress any video to an exact target size, locally, and prove the quality did not drop.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,6 +22,8 @@
22
22
  "src",
23
23
  "web",
24
24
  "README.md",
25
+ "README.zh-CN.md",
26
+ "CHANGELOG.md",
25
27
  "LICENSE"
26
28
  ],
27
29
  "scripts": {
package/src/cli.mjs CHANGED
@@ -7,13 +7,15 @@ import { encode } from "./encode.mjs";
7
7
  import { measureQuality, verdict } from "./quality.mjs";
8
8
  import { bytes, duration, bitrate, ratio, parseSize, clock } from "./human.mjs";
9
9
 
10
- const VERSION = "0.1.3";
10
+ const VERSION = "0.1.4";
11
11
 
12
12
  const HELP = `
13
13
  halfsize ${VERSION} - compress a video to an exact size, and prove it still looks the same.
14
14
 
15
15
  Usage
16
16
  halfsize <file> --to 25mb Compress to a target size
17
+ halfsize <file> --for discord Use a platform's upload limit
18
+ halfsize a.mp4 b.mp4 --to 10mb Compress several files in a row
17
19
  halfsize <file> --to 25mb --codec h265
18
20
  halfsize <file> --crf 23 Quality-first mode
19
21
  halfsize --web Open the local app
@@ -22,10 +24,12 @@ const HELP = `
22
24
 
23
25
  Options
24
26
  --to <size> Target size: 25mb, 700k, 1.5gb
27
+ --for <app> Target cap for discord, whatsapp, wechat, email, twitter
25
28
  --crf <n> Quality mode (18 best / 23 balanced / 28 small)
26
29
  --codec <name> h264 (default, compatible) or h265 (smaller)
27
30
  --preset <name> ultrafast..veryslow (default: medium)
28
31
  --out <file> Output path
32
+ --no-audio Drop the audio track
29
33
  --no-verify Skip the SSIM/VMAF check
30
34
  --no-downscale Never reduce resolution
31
35
  --json Machine-readable result
@@ -40,7 +44,26 @@ const HELP = `
40
44
  resolution, encodes twice, then measures the result with SSIM and VMAF.
41
45
  `;
42
46
 
43
- const DEFAULTS = { codec: "h264", preset: "medium", verify: true, downscale: true };
47
+ const DEFAULTS = { codec: "h264", preset: "medium", verify: true, downscale: true, audio: true };
48
+
49
+ // Current upload caps as people hit them. Caps change, so --to always wins if a
50
+ // preset is wrong for your account tier.
51
+ const PLATFORMS = {
52
+ discord: { bytes: 10 * 1_000_000, label: "Discord free (10 MB)" },
53
+ whatsapp: { bytes: 16 * 1_000_000, label: "WhatsApp media (16 MB)" },
54
+ wechat: { bytes: 25 * 1_000_000, label: "WeChat video (25 MB)" },
55
+ email: { bytes: 20 * 1_000_000, label: "Email attachment (20 MB)" },
56
+ twitter: { bytes: 512 * 1_000_000, label: "X / Twitter (512 MB)" },
57
+ };
58
+
59
+ function platformFor(name) {
60
+ const key = String(name || "").toLowerCase();
61
+ const found = PLATFORMS[key];
62
+ if (!found) {
63
+ throw new Error(`Unknown platform "${name}". Use one of: ${Object.keys(PLATFORMS).join(", ")}.`);
64
+ }
65
+ return { key, ...found };
66
+ }
44
67
 
45
68
  function parseArgs(argv) {
46
69
  const args = { _: [], flags: { ...DEFAULTS } };
@@ -52,10 +75,12 @@ function parseArgs(argv) {
52
75
  else if (a === "--dry-run") args.flags.dryRun = true;
53
76
  else if (a === "--no-verify") args.flags.verify = false;
54
77
  else if (a === "--no-downscale") args.flags.downscale = false;
78
+ else if (a === "--no-audio") args.flags.audio = false;
55
79
  else if (a === "--web") args.flags.web = true;
56
80
  else if (a === "--doctor") args.flags.doctor = true;
57
81
  else if (a === "--port") args.flags.port = Number(argv[++i]);
58
82
  else if (a === "--to") args.flags.to = argv[++i];
83
+ else if (a === "--for") args.flags.for = argv[++i];
59
84
  else if (a === "--crf") args.flags.crf = Number(argv[++i]);
60
85
  else if (a === "--codec") args.flags.codec = argv[++i];
61
86
  else if (a === "--preset") args.flags.preset = argv[++i];
@@ -81,18 +106,53 @@ export async function main(argv) {
81
106
  if (flags.doctor) return doctor();
82
107
  if (flags.web) return startWeb(flags.port);
83
108
 
84
- 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.");
109
+ if (flags.to && flags.for) throw new Error("Use --to or --for, not both.");
110
+ const platform = flags.for ? platformFor(flags.for) : null;
111
+ if (!flags.to && !platform && flags.crf === undefined) {
112
+ throw new Error("Pick a target: --to 25mb, --for discord, or --crf 23 for quality mode.");
113
+ }
114
+ if (flags.out && args._.length > 1) {
115
+ throw new Error("--out only works with one input file. It would overwrite itself in a batch.");
116
+ }
117
+
118
+ const inputs = args._.map((file) => path.resolve(file));
119
+ const targetBytes = platform ? platform.bytes : flags.to ? parseSize(flags.to) : null;
120
+ const batch = inputs.length > 1;
121
+
122
+ const results = [];
123
+ let failed = 0;
124
+ for (const input of inputs) {
125
+ try {
126
+ results.push(await processFile(input, flags, targetBytes, platform, batch));
127
+ } catch (err) {
128
+ failed++;
129
+ results.push({ input: { file: input }, error: err?.message || String(err) });
130
+ if (!flags.json) console.error(`\n x ${path.basename(input)}: ${err?.message || err}`);
131
+ if (process.env.HALFSIZE_DEBUG) console.error(err?.stack);
132
+ }
88
133
  }
89
134
 
90
- 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
+
91
147
  const info = await probe(input);
92
148
 
93
149
  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;
150
+ if (batch) {
151
+ console.log(`\n ${path.basename(input)} already fits (${bytes(info.sizeBytes)}), skipped.`);
152
+ } else {
153
+ console.log(`\n This file is already ${bytes(info.sizeBytes)}, smaller than the ${bytes(targetBytes)} target.\n`);
154
+ }
155
+ return { input: { file: input, bytes: info.sizeBytes }, targetBytes, skipped: "already-smaller" };
96
156
  }
97
157
 
98
158
  const plan = planEncode(info, {
@@ -101,17 +161,20 @@ export async function main(argv) {
101
161
  codec: flags.codec,
102
162
  preset: flags.preset,
103
163
  downscale: flags.downscale,
164
+ audio: flags.audio,
104
165
  });
105
166
 
106
- const outPath = flags.out
107
- ? path.resolve(flags.out)
108
- : defaultOutput(input, flags, plan);
167
+ const outPath = flags.out ? path.resolve(flags.out) : defaultOutput(input, flags, plan);
109
168
 
110
- if (!flags.json) printPlan(info, plan, flags, outPath);
169
+ if (!flags.json) printPlan(info, plan, flags, outPath, platform, !batch);
111
170
 
112
171
  if (flags.dryRun) {
113
- if (flags.json) console.log(JSON.stringify({ info, plan, out: outPath }, null, 2));
114
- return;
172
+ return {
173
+ info,
174
+ plan,
175
+ out: outPath,
176
+ dryRun: true,
177
+ };
115
178
  }
116
179
 
117
180
  const progress = new Progress(Boolean(process.stdout.isTTY) && !flags.json, info.duration);
@@ -123,7 +186,7 @@ export async function main(argv) {
123
186
  }
124
187
 
125
188
  let quality = { ssim: null, vmaf: null };
126
- if (flags.verify && !flags.noVerify) {
189
+ if (flags.verify) {
127
190
  if (!flags.json) process.stdout.write(` measuring quality `);
128
191
  quality = await measureQuality(input, outPath, info.duration);
129
192
  if (!flags.json) process.stdout.write(`\r${" ".repeat(40)}\r`);
@@ -142,19 +205,34 @@ export async function main(argv) {
142
205
  elapsedMs: result.elapsedMs,
143
206
  };
144
207
 
145
- if (flags.json) {
146
- console.log(JSON.stringify(summary, null, 2));
147
- return;
148
- }
208
+ if (!flags.json) printResult(summary, outPath);
209
+ return summary;
210
+ }
149
211
 
150
- 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("");
151
225
  }
152
226
 
153
227
  function defaultOutput(input, flags, plan) {
154
228
  const dir = path.dirname(input);
155
229
  const ext = path.extname(input);
156
230
  const base = path.basename(input, ext);
157
- const tag = flags.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}`;
158
236
  return path.join(dir, `${base}.${tag}.mp4`);
159
237
  }
160
238
 
@@ -162,16 +240,18 @@ function line(label, value) {
162
240
  console.log(` ${label.padEnd(10)} ${value}`);
163
241
  }
164
242
 
165
- function printPlan(info, plan, flags, outPath) {
166
- console.log("");
167
- console.log(` halfsize ${VERSION}`);
243
+ function printPlan(info, plan, flags, outPath, platform, showHeader = true) {
168
244
  console.log("");
245
+ if (showHeader) {
246
+ console.log(` halfsize ${VERSION}`);
247
+ console.log("");
248
+ }
169
249
  console.log(` ${path.basename(info.file)}`);
170
250
  console.log(
171
251
  ` ${bytes(info.sizeBytes)} ${duration(info.duration)} ${info.video.width}x${info.video.height} ${info.video.fps.toFixed(2)} fps ${info.video.codec}`,
172
252
  );
173
253
  console.log("");
174
- if (plan.mode === "size") line("target", bytes(plan.targetBytes));
254
+ if (plan.mode === "size") line("target", platform ? `${bytes(plan.targetBytes)} (${platform.label})` : bytes(plan.targetBytes));
175
255
  else line("mode", `CRF ${plan.crf}`);
176
256
  line(
177
257
  "plan",
package/src/encode.mjs CHANGED
@@ -15,6 +15,10 @@ function buildFilters(plan) {
15
15
 
16
16
  function baseArgs(input, plan, passlog) {
17
17
  const args = ["-hide_banner", "-y", "-i", input];
18
+ // Map exactly the streams we mean to encode. Without this, ffmpeg can pick a
19
+ // cover art / thumbnail stream on files that carry one.
20
+ args.push("-map", "0:v:0");
21
+ if (plan.audioKbps) args.push("-map", "0:a:0?");
18
22
  const filters = buildFilters(plan);
19
23
  if (filters) args.push("-vf", filters);
20
24
  args.push("-fps_mode", "cfr");
@@ -28,7 +32,7 @@ function baseArgs(input, plan, passlog) {
28
32
 
29
33
  function audioArgs(plan) {
30
34
  if (!plan.audioKbps) return ["-an"];
31
- return ["-c:a", "aac", "-b:a", `${plan.audioKbps}k`, "-ac", "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,