vid-com 1.0.10 → 1.0.12

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/README.md CHANGED
@@ -19,6 +19,7 @@ brew install ffmpeg
19
19
  ```bash
20
20
  # Global install (recommended) / 全局安装(推荐)
21
21
  npm install -g vid-com
22
+ ```
22
23
 
23
24
 
24
25
  ## Commands / 命令
@@ -57,6 +58,34 @@ Output JSON format / 输出 JSON 格式:
57
58
 
58
59
  ---
59
60
 
61
+ ### pick — Interactive folder-tree selection / 树形交互选择压缩
62
+
63
+ Runs from the current folder by default, asks whether to recurse into subfolders, then lets you select videos from a tree UI. Use ↑/↓ or `j`/`k` to move, Space to select a file or folder, `a` to select all/none, and Enter to compress the selected videos.
64
+
65
+ 默认在当前文件夹运行,先询问是否递归遍历子文件夹,然后以树形目录展示视频文件。用 ↑/↓ 或 `j`/`k` 移动,空格选择文件或整个文件夹,`a` 全选/清空,回车开始压缩已选视频。
66
+
67
+ ```bash
68
+ cd ~/Videos
69
+ vid-com pick
70
+ ```
71
+
72
+ | Option | Description | Default |
73
+ |--------|-------------|---------|
74
+ | `--dir` | Directory to scan / 扫描目录 | current directory / 当前目录 |
75
+ | `--recursive` | Recurse into subdirectories / 递归遍历 | prompts if omitted / 未指定时询问 |
76
+ | `--no-recursive` | Do not recurse / 不递归 | — |
77
+ | `--output` | Keep selected JSON list / 保留已选列表 JSON | temp file / 临时文件 |
78
+
79
+ `pick` accepts the same compression options as `compress`, such as `--outdir`, `--suffix`, `-q`, `--target`, `--audio`, `--hdr`, and `--lut`.
80
+
81
+ `pick` 支持 `compress` 的同一组压缩参数,例如 `--outdir`、`--suffix`、`-q`、`--target`、`--audio`、`--hdr`、`--lut`。
82
+
83
+ ```bash
84
+ vid-com pick --dir ~/Videos --recursive --outdir ~/Videos/out -q 65
85
+ ```
86
+
87
+ ---
88
+
60
89
  ### compress — Batch compress / 批量压缩
61
90
 
62
91
  Reads the JSON list from `find`, compresses files sequentially with a real-time progress bar.
@@ -75,11 +104,14 @@ vid-com compress --input <json-file> [options]
75
104
  | `-q` / `--quality` | Quality value 0–100, higher is better / 画质值(越高越好) | `65` |
76
105
  | `--target` | Fixed bitrate in kbps, overrides `-q` / 固定码率,指定后忽略 `-q` | — |
77
106
  | `--audio` | Audio bitrate in kbps / 音频码率(kbps) | `128` |
107
+ | `--hdr` | Output HDR10 10-bit HEVC with BT.2020/PQ metadata / 输出 HDR10 10-bit HEVC 并写入 BT.2020/PQ 色彩标记 | off |
108
+ | `--lut <file.cube>` | Apply a 3D LUT before encoding / 编码前应用 `.cube` LUT | — |
78
109
 
79
110
  **Encoder notes / 编码说明:**
80
111
  - Encoder / 编码器: `hevc_videotoolbox` (Apple hardware H.265)
81
112
  - Decoder / 解码器: `videotoolbox` (hardware decode, lower CPU load)
82
113
  - Pixel format / 像素格式: `nv12` (native VideoToolbox format)
114
+ - HDR pixel format / HDR 像素格式: `p010le` with BT.2020/PQ metadata when `--hdr` is enabled
83
115
  - Codec tag / codec tag: `hvc1` (QuickTime / Final Cut / iOS compatible)
84
116
 
85
117
  ```bash
@@ -91,6 +123,12 @@ vid-com compress --input dji.json --target 8000 --outdir ./out
91
123
 
92
124
  # Custom quality and audio bitrate / 自定义画质和音频码率
93
125
  vid-com compress --input dji.json -q 50 --outdir ./out --suffix _h265 --audio 192
126
+
127
+ # HDR output / 输出 HDR 视频
128
+ vid-com compress --input dji.json --outdir ./out --hdr --suffix _hdr
129
+
130
+ # Apply LUT and output HDR / 加 LUT 并输出 HDR
131
+ vid-com compress --input dji.json --outdir ./out --lut ~/Looks/film.cube --hdr --suffix _hdr_lut
94
132
  ```
95
133
 
96
134
  Progress bar example / 进度条示例:
@@ -196,6 +234,9 @@ Source metadata check / 源信息对比(`sips`):
196
234
 
197
235
  ```bash
198
236
  # Video workflow / 视频工作流
237
+ vid-com pick --dir ~/Videos
238
+
239
+ # Step-by-step workflow / 分步工作流
199
240
  vid-com find --dir ~/Videos --recursive --output /tmp/list.json
200
241
  vid-com compress --input /tmp/list.json --outdir ~/Videos/out
201
242
  vid-com delete --input /tmp/list.json
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vid-com",
3
- "version": "1.0.10",
3
+ "version": "1.0.12",
4
4
  "description": "Video bitrate scanner & batch compressor with ARW→JPEG conversion (macOS, FFmpeg, hevc_videotoolbox)",
5
5
  "main": "video_bitrate_tool.js",
6
6
  "bin": {
@@ -1,13 +1,23 @@
1
1
  #!/bin/bash
2
2
 
3
3
  # 1. 处理输入参数
4
- if [ -z "$1" ]; then
5
- echo "用法: $0 <源目录> [输出目录]"
4
+ DELETE_ORIG=0
5
+ POSITIONALS=()
6
+ for arg in "$@"; do
7
+ case "$arg" in
8
+ -d) DELETE_ORIG=1 ;;
9
+ *) POSITIONALS+=("$arg") ;;
10
+ esac
11
+ done
12
+
13
+ if [ ${#POSITIONALS[@]} -eq 0 ]; then
14
+ echo "用法: $0 [-d] <源目录> [输出目录]"
15
+ echo " -d 转换完成后删除原始 ARW/DNG 文件"
6
16
  exit 1
7
17
  fi
8
18
 
9
- SOURCE_DIR="$1"
10
- OUTPUT_DIR="${2:-$SOURCE_DIR}"
19
+ SOURCE_DIR="${POSITIONALS[0]}"
20
+ OUTPUT_DIR="${POSITIONALS[1]:-}"
11
21
 
12
22
  # 2. 检查源目录是否存在
13
23
  if [ ! -d "$SOURCE_DIR" ]; then
@@ -15,7 +25,9 @@ if [ ! -d "$SOURCE_DIR" ]; then
15
25
  exit 1
16
26
  fi
17
27
 
18
- mkdir -p "$OUTPUT_DIR"
28
+ if [ -n "$OUTPUT_DIR" ]; then
29
+ mkdir -p "$OUTPUT_DIR"
30
+ fi
19
31
 
20
32
  # 3. 计算并发数:当前 CPU 数量 - 1 (最少为 1)
21
33
  CPU_CORES=$(sysctl -n hw.ncpu)
@@ -24,24 +36,46 @@ THREADS=$((CPU_CORES > 1 ? CPU_CORES - 1 : 1))
24
36
  echo "------------------------------------------"
25
37
  echo "系统 CPU 核心数: $CPU_CORES"
26
38
  echo "并发线程数: $THREADS"
27
- echo "正在开始平级转换..."
39
+ if [ -n "$OUTPUT_DIR" ]; then
40
+ echo "输出目录: $OUTPUT_DIR(保留子目录结构)"
41
+ else
42
+ echo "输出模式: 与源文件同级"
43
+ fi
44
+ [ "$DELETE_ORIG" -eq 1 ] && echo "转换后删除原始文件: 是"
45
+ echo "正在开始转换..."
28
46
  echo "------------------------------------------"
29
47
 
30
48
  # 4. 定义转换函数并导出,以便 xargs 调用
31
- # 使用 export -f 需要在脚本环境运行
32
49
  do_convert() {
33
50
  local src_file="$1"
34
- local out_dir="$2"
35
-
36
- # 获取纯文件名 (不带路径)
51
+ local src_dir="$2"
52
+ local out_dir="$3"
53
+ local delete_orig="$4"
54
+
37
55
  local filename=$(basename "$src_file")
38
- # 构造目标路径 (平级输出)
39
- local target_file="$out_dir/${filename%.*}.jpg"
40
-
56
+ local target_file
57
+
58
+ if [ -n "$out_dir" ]; then
59
+ # 计算相对于源目录的子路径,保留目录结构
60
+ local rel_dir
61
+ rel_dir=$(dirname "${src_file#${src_dir}/}")
62
+ local target_dir="$out_dir/$rel_dir"
63
+ mkdir -p "$target_dir"
64
+ target_file="$target_dir/${filename%.*}.jpg"
65
+ else
66
+ # 输出到与源文件同级
67
+ target_file="$(dirname "$src_file")/${filename%.*}.jpg"
68
+ fi
69
+
41
70
  echo "正在转换: $filename"
42
-
43
- # 执行转换
44
- sips -s format jpeg -s formatOptions 90 "$src_file" --out "$target_file" > /dev/null 2>&1
71
+ if sips -s format jpeg -s formatOptions 90 "$src_file" --out "$target_file" > /dev/null 2>&1; then
72
+ if [ "$delete_orig" -eq 1 ]; then
73
+ rm -f "$src_file"
74
+ echo "已删除原始文件: $filename"
75
+ fi
76
+ else
77
+ echo "转换失败: $filename"
78
+ fi
45
79
  }
46
80
 
47
81
  export -f do_convert
@@ -50,7 +84,11 @@ export -f do_convert
50
84
  # -0: 处理带空格的文件名
51
85
  # -P: 并发数
52
86
  # -I: 占位符
53
- find "$SOURCE_DIR" -type f \( -iname "*.arw" -o -iname "*.dng" \) -print0 | xargs -0 -I {} -P "$THREADS" bash -c "do_convert '{}' '$OUTPUT_DIR'"
87
+ find "$SOURCE_DIR" -type f \( -iname "*.arw" -o -iname "*.dng" \) -print0 | xargs -0 -I {} -P "$THREADS" bash -c "do_convert '{}' '$SOURCE_DIR' '$OUTPUT_DIR' '$DELETE_ORIG'"
54
88
 
55
89
  echo "------------------------------------------"
56
- echo "转换完成!所有文件已输出到: $OUTPUT_DIR"
90
+ if [ -n "$OUTPUT_DIR" ]; then
91
+ echo "转换完成!所有文件已输出到: $OUTPUT_DIR"
92
+ else
93
+ echo "转换完成!JPG 文件已输出到各自源文件所在目录。"
94
+ fi
@@ -10,13 +10,15 @@
10
10
  * 用法:
11
11
  * node video_bitrate_tool.js find --dir /path/to/videos --threshold 4000 [--output list.json] [--recursive]
12
12
  * node video_bitrate_tool.js compress --input list.json --target 2000 [--outdir /path/to/output] [--suffix _compressed]
13
+ * node video_bitrate_tool.js pick [--dir /path/to/videos] [--recursive] [compress options]
13
14
  *
14
15
  * 依赖:ffprobe / ffmpeg(需已安装并在 PATH 中)
15
16
  */
16
17
 
17
- const { execSync, spawn, spawnSync } = require("child_process");
18
+ const { execFileSync, spawn, spawnSync } = require("child_process");
18
19
  const readline = require("readline");
19
20
  const fs = require("fs");
21
+ const os = require("os");
20
22
  const path = require("path");
21
23
 
22
24
  // ─── 工具函数 ────────────────────────────────────────────────────────────────
@@ -36,6 +38,10 @@ function listFiles(dir, recursive) {
36
38
  return results;
37
39
  }
38
40
 
41
+ function execOutput(command, args) {
42
+ return execFileSync(command, args, { stdio: ["ignore", "pipe", "pipe"] }).toString();
43
+ }
44
+
39
45
  /** 视频扩展名白名单 */
40
46
  const VIDEO_EXTS = new Set([
41
47
  ".mp4", ".mkv", ".avi", ".wmv",
@@ -52,21 +58,24 @@ function isVideo(filePath) {
52
58
  */
53
59
  function getVideoBitrate(filePath) {
54
60
  try {
55
- const raw = execSync(
56
- `ffprobe -v error -select_streams v:0 -show_entries stream=bit_rate ` +
57
- `-of default=noprint_wrappers=1:nokey=1 "${filePath}"`,
58
- { stdio: ["pipe", "pipe", "pipe"] }
59
- ).toString().trim();
61
+ const raw = execOutput("ffprobe", [
62
+ "-v", "error",
63
+ "-select_streams", "v:0",
64
+ "-show_entries", "stream=bit_rate",
65
+ "-of", "default=noprint_wrappers=1:nokey=1",
66
+ filePath,
67
+ ]).trim();
60
68
 
61
69
  let kbps = parseInt(raw, 10);
62
70
  if (!isNaN(kbps) && kbps > 0) return Math.round(kbps / 1000);
63
71
 
64
72
  // 回退:读取容器级别码率
65
- const raw2 = execSync(
66
- `ffprobe -v error -show_entries format=bit_rate ` +
67
- `-of default=noprint_wrappers=1:nokey=1 "${filePath}"`,
68
- { stdio: ["pipe", "pipe", "pipe"] }
69
- ).toString().trim();
73
+ const raw2 = execOutput("ffprobe", [
74
+ "-v", "error",
75
+ "-show_entries", "format=bit_rate",
76
+ "-of", "default=noprint_wrappers=1:nokey=1",
77
+ filePath,
78
+ ]).trim();
70
79
  kbps = parseInt(raw2, 10);
71
80
  return (!isNaN(kbps) && kbps > 0) ? Math.round(kbps / 1000) : null;
72
81
  } catch {
@@ -83,24 +92,122 @@ function fmtSize(bytes) {
83
92
 
84
93
  // ─── 解析命令行参数 ──────────────────────────────────────────────────────────
85
94
 
95
+ const BOOLEAN_ARGS = new Set(["recursive", "hdr", "help", "h"]);
96
+
97
+ function parseBoolLiteral(value) {
98
+ const text = String(value).trim().toLowerCase();
99
+ if (["1", "true", "yes", "y", "on"].includes(text)) return true;
100
+ if (["0", "false", "no", "n", "off"].includes(text)) return false;
101
+ return null;
102
+ }
103
+
86
104
  function parseArgs(argv) {
87
105
  const args = {};
88
106
  for (let i = 0; i < argv.length; i++) {
89
107
  const isLong = argv[i].startsWith("--");
90
108
  const isShort = !isLong && argv[i].startsWith("-") && argv[i].length === 2;
91
109
  if (isLong || isShort) {
92
- const key = isLong ? argv[i].slice(2) : argv[i].slice(1);
93
- args[key] = argv[i + 1] && !argv[i + 1].startsWith("-") ? argv[++i] : true;
110
+ const rawKey = isLong ? argv[i].slice(2) : argv[i].slice(1);
111
+ if (isLong && rawKey.startsWith("no-")) {
112
+ args[rawKey.slice(3)] = false;
113
+ continue;
114
+ }
115
+
116
+ const key = rawKey;
117
+ const next = argv[i + 1];
118
+ if (BOOLEAN_ARGS.has(key)) {
119
+ const boolVal = next !== undefined ? parseBoolLiteral(next) : null;
120
+ if (boolVal !== null) {
121
+ args[key] = boolVal;
122
+ i++;
123
+ } else {
124
+ args[key] = true;
125
+ }
126
+ } else {
127
+ args[key] = next && !next.startsWith("-") ? argv[++i] : true;
128
+ }
94
129
  }
95
130
  }
96
131
  return args;
97
132
  }
98
133
 
134
+ function parsePositionals(argv) {
135
+ const positionals = [];
136
+ for (let i = 0; i < argv.length; i++) {
137
+ const token = argv[i];
138
+ const isLong = token.startsWith("--");
139
+ const isShort = !isLong && token.startsWith("-") && token.length === 2;
140
+ if (isLong || isShort) {
141
+ const rawKey = isLong ? token.slice(2) : token.slice(1);
142
+ if (isLong && rawKey.startsWith("no-")) continue;
143
+
144
+ const next = argv[i + 1];
145
+ if (BOOLEAN_ARGS.has(rawKey)) {
146
+ if (next !== undefined && parseBoolLiteral(next) !== null) i++;
147
+ } else if (next && !next.startsWith("-")) {
148
+ i++;
149
+ }
150
+ } else {
151
+ positionals.push(token);
152
+ }
153
+ }
154
+ return positionals;
155
+ }
156
+
157
+ // ─── 读取 ~/.vidComRc 配置 ───────────────────────────────────────────────────
158
+
159
+ /**
160
+ * 加载 ~/.vidComRc(JSON 格式),返回配置对象。
161
+ * 若文件不存在或解析失败则返回 {}。
162
+ *
163
+ * 支持字段:
164
+ * threshold <number> find 码率阈值(kbps),默认 4000
165
+ * target <number> compress 固定目标码率(kbps)
166
+ * quality <number> compress quality 值(0-100),默认 65
167
+ * outdir <string> compress/findComDel 默认输出目录
168
+ * suffix <string> compress 输出文件名后缀,默认 _compressed
169
+ * audio <number> compress 音频码率(kbps),默认 128
170
+ * hdr <boolean> compress 输出 HDR10 色彩标记与 10-bit HEVC,默认 false
171
+ * lut <string> compress 使用 .cube LUT 文件
172
+ * recursive <boolean> find 是否递归扫描,默认 false
173
+ */
174
+ function loadConfig() {
175
+ const rcPath = path.join(process.env.HOME || "~", ".vidComRc");
176
+ if (!fs.existsSync(rcPath)) return {};
177
+ try {
178
+ const raw = fs.readFileSync(rcPath, "utf8");
179
+ const cfg = JSON.parse(raw);
180
+ if (typeof cfg !== "object" || Array.isArray(cfg)) {
181
+ console.warn("⚠️ ~/.vidComRc 格式错误(应为 JSON 对象),已忽略");
182
+ return {};
183
+ }
184
+ return cfg;
185
+ } catch (err) {
186
+ console.warn(`⚠️ 读取 ~/.vidComRc 失败:${err.message},已忽略`);
187
+ return {};
188
+ }
189
+ }
190
+
191
+ /**
192
+ * 将配置文件默认值与命令行 args 合并。
193
+ * CLI 参数优先级高于配置文件。
194
+ */
195
+ function mergeConfig(args, config) {
196
+ const merged = { ...args };
197
+ for (const [key, val] of Object.entries(config)) {
198
+ // CLI 中未显式传入的参数才使用配置文件默认值
199
+ if (merged[key] === undefined) {
200
+ merged[key] = val;
201
+ }
202
+ }
203
+ return merged;
204
+ }
205
+
99
206
  // ─── Mode 1: find ────────────────────────────────────────────────────────────
100
207
 
101
208
  function runFind(args) {
102
209
  const dir = args.dir;
103
- const threshold = parseInt(args.threshold || "4000", 10); // kbps
210
+ const threshold = parseInt(args.threshold ?? "4000", 10); // kbps
104
211
  const outFile = args.output || "bitrate_list.json";
105
212
  const recursive = !!args.recursive;
106
213
 
@@ -149,10 +256,12 @@ function runFind(args) {
149
256
  /** 用 ffprobe 获取视频时长(秒),失败返回 0 */
150
257
  function getVideoDuration(filePath) {
151
258
  try {
152
- const raw = execSync(
153
- `ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${filePath}"`,
154
- { stdio: ["pipe", "pipe", "pipe"] }
155
- ).toString().trim();
259
+ const raw = execOutput("ffprobe", [
260
+ "-v", "error",
261
+ "-show_entries", "format=duration",
262
+ "-of", "default=noprint_wrappers=1:nokey=1",
263
+ filePath,
264
+ ]).trim();
156
265
  const sec = parseFloat(raw);
157
266
  return isNaN(sec) ? 0 : sec;
158
267
  } catch {
@@ -169,13 +278,50 @@ function renderProgress(pct, speed, eta) {
169
278
  process.stdout.write(`\r [${bar}] ${pct.toFixed(1).padStart(5)}% ${speed.padEnd(8)} ${etaStr} `);
170
279
  }
171
280
 
281
+ function asBool(value) {
282
+ if (value === true) return true;
283
+ if (value === false || value === undefined || value === null) return false;
284
+ const text = String(value).trim().toLowerCase();
285
+ return !["0", "false", "no", "off"].includes(text);
286
+ }
287
+
288
+ function escapeFilterPath(filePath) {
289
+ return path.resolve(filePath)
290
+ .replace(/\\/g, "\\\\")
291
+ .replace(/:/g, "\\:")
292
+ .replace(/'/g, "\\'");
293
+ }
294
+
295
+ function buildVideoFilterArgs({ lutPath, hdr }) {
296
+ const filters = [];
297
+ if (lutPath) {
298
+ filters.push(`lut3d=file='${escapeFilterPath(lutPath)}'`);
299
+ filters.push(`format=${hdr ? "p010le" : "nv12"}`);
300
+ }
301
+ return filters.length > 0 ? ["-vf", filters.join(",")] : [];
302
+ }
303
+
304
+ function buildColorArgs({ hdr }) {
305
+ if (!hdr) return ["-pix_fmt", "nv12"];
306
+ return [
307
+ "-pix_fmt", "p010le",
308
+ "-profile:v", "main10",
309
+ "-color_primaries", "bt2020",
310
+ "-color_trc", "smpte2084",
311
+ "-colorspace", "bt2020nc",
312
+ "-color_range", "tv",
313
+ ];
314
+ }
315
+
172
316
  /** 压缩单个文件,返回 Promise */
173
- function compressOne(srcPath, destPath, targetKbps, qualityVal, audioKbps, durationSec) {
317
+ function compressOne(srcPath, destPath, targetKbps, qualityVal, audioKbps, durationSec, options = {}) {
174
318
  return new Promise((resolve, reject) => {
175
319
  // targetKbps === null 表示使用 quality 模式
176
320
  const videoArgs = targetKbps !== null
177
321
  ? ["-b:v", `${targetKbps}k`]
178
322
  : ["-q:v", String(qualityVal)];
323
+ const filterArgs = buildVideoFilterArgs(options);
324
+ const colorArgs = buildColorArgs(options);
179
325
 
180
326
  const ffmpegArgs = [
181
327
  "-y",
@@ -183,9 +329,10 @@ function compressOne(srcPath, destPath, targetKbps, qualityVal, audioKbps, durat
183
329
  "-i", srcPath,
184
330
  "-progress", "pipe:1", // 结构化进度 → stdout
185
331
  "-nostats",
332
+ ...filterArgs,
186
333
  "-c:v", "hevc_videotoolbox", // 硬件编码 H.265
187
334
  "-tag:v", "hvc1", // QuickTime/Apple 兼容 tag
188
- "-pix_fmt", "nv12", // 与 videotoolbox 原生格式对齐,避免色彩转换
335
+ ...colorArgs,
189
336
  ...videoArgs,
190
337
  "-map_metadata", "0", // 保留原始 meta(创建时间、GPS 等)
191
338
  "-c:a", "aac",
@@ -194,10 +341,11 @@ function compressOne(srcPath, destPath, targetKbps, qualityVal, audioKbps, durat
194
341
  destPath,
195
342
  ];
196
343
 
197
- const proc = spawn("ffmpeg", ffmpegArgs, { stdio: ["ignore", "pipe", "ignore"] });
344
+ const proc = spawn("ffmpeg", ffmpegArgs, { stdio: ["ignore", "pipe", "pipe"] });
198
345
 
199
346
  const startTime = Date.now();
200
347
  let buf = "";
348
+ let errBuf = "";
201
349
 
202
350
  proc.stdout.on("data", (chunk) => {
203
351
  buf += chunk.toString();
@@ -218,10 +366,17 @@ function compressOne(srcPath, destPath, targetKbps, qualityVal, audioKbps, durat
218
366
  }
219
367
  });
220
368
 
369
+ proc.stderr.on("data", (chunk) => {
370
+ errBuf = (errBuf + chunk.toString()).slice(-4000);
371
+ });
372
+
221
373
  proc.on("close", (code) => {
222
374
  process.stdout.write("\n");
223
375
  if (code === 0) resolve();
224
- else reject(new Error(`ffmpeg 退出码 ${code}`));
376
+ else {
377
+ const details = errBuf.trim().split("\n").slice(-8).join("\n");
378
+ reject(new Error(`ffmpeg 退出码 ${code}${details ? `\n${details}` : ""}`));
379
+ }
225
380
  });
226
381
 
227
382
  proc.on("error", reject);
@@ -234,7 +389,9 @@ async function runCompress(args) {
234
389
  const qualityVal = args.quality ?? args.q ?? "65"; // -q / --quality,默认 65
235
390
  const outDir = args.outdir || null;
236
391
  const suffix = args.suffix || "_compressed";
237
- const audioKbps = args.audio || "128";
392
+ const audioKbps = String(args.audio || "128");
393
+ const hdr = asBool(args.hdr);
394
+ const lutPath = args.lut || null;
238
395
 
239
396
  if (!inputFile) {
240
397
  console.error("❌ 请用 --input 指定 JSON 列表文件");
@@ -250,11 +407,19 @@ async function runCompress(args) {
250
407
  console.log("⚠️ 列表为空,无需处理");
251
408
  return;
252
409
  }
410
+ if (lutPath && !fs.existsSync(lutPath)) {
411
+ console.error(`❌ LUT 文件不存在:${lutPath}`);
412
+ process.exit(1);
413
+ }
253
414
 
254
415
  if (outDir) fs.mkdirSync(outDir, { recursive: true });
255
416
 
256
417
  const modeDesc = fixedTargetKbps ? `固定码率 ${fixedTargetKbps} kbps` : `Quality 模式(-q:v ${qualityVal})`;
257
- console.log(`\n🎬 开始压缩 | ${modeDesc} | 编码:hevc_videotoolbox\n`);
418
+ const extras = [
419
+ hdr ? "HDR10 10-bit" : null,
420
+ lutPath ? `LUT: ${path.basename(lutPath)}` : null,
421
+ ].filter(Boolean).join(" | ");
422
+ console.log(`\n🎬 开始压缩 | ${modeDesc} | 编码:hevc_videotoolbox${extras ? ` | ${extras}` : ""}\n`);
258
423
 
259
424
  let done = 0, failed = 0;
260
425
 
@@ -278,13 +443,15 @@ async function runCompress(args) {
278
443
 
279
444
  console.log(`▶ [${i + 1}/${list.length}] ${path.basename(srcPath)}`);
280
445
  console.log(` 原始码率:${item.bitrate_kbps ?? "?"} kbps → ${targetKbps !== null ? `${targetKbps} kbps` : `Quality -q:v ${qualityVal}`}`);
446
+ if (hdr) console.log(" HDR:10-bit HEVC + BT.2020/PQ metadata");
447
+ if (lutPath) console.log(` LUT:${lutPath}`);
281
448
  console.log(` 输出:${destPath}`);
282
449
 
283
450
  const durationSec = getVideoDuration(srcPath);
284
451
  const startTime = Date.now();
285
452
 
286
453
  try {
287
- await compressOne(srcPath, destPath, targetKbps, qualityVal, audioKbps, durationSec);
454
+ await compressOne(srcPath, destPath, targetKbps, qualityVal, audioKbps, durationSec, { hdr, lutPath });
288
455
 
289
456
  const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
290
457
  const newSize = fs.statSync(destPath).size;
@@ -300,6 +467,7 @@ async function runCompress(args) {
300
467
  }
301
468
 
302
469
  console.log(`\n🏁 全部完成:成功 ${done} 个,失败 ${failed} 个\n`);
470
+ if (failed > 0) process.exitCode = 1;
303
471
  }
304
472
 
305
473
  // ─── 共用删除工具 ─────────────────────────────────────────────────────────────
@@ -328,32 +496,282 @@ function askKey(question) {
328
496
  });
329
497
  }
330
498
 
331
- // ─── Mode 3: delete ───────────────────────────────────────────────────────────
499
+ async function askYesNo(question, defaultValue = false) {
500
+ if (!process.stdin.isTTY) return defaultValue;
501
+ const hint = defaultValue ? "[Y/n]" : "[y/N]";
502
+ const key = await askKey(`${question} ${hint}: `);
503
+ const text = String(key).trim().toLowerCase();
504
+ if (!text) return defaultValue;
505
+ if (["y", "yes"].includes(text)) return true;
506
+ if (["n", "no"].includes(text)) return false;
507
+ return defaultValue;
508
+ }
509
+
510
+ // ─── Mode 3: pick ────────────────────────────────────────────────────────────
511
+
512
+ function compareDirEntries(a, b) {
513
+ if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1;
514
+ return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" });
515
+ }
516
+
517
+ function safeReadDir(dir) {
518
+ try {
519
+ return fs.readdirSync(dir, { withFileTypes: true }).sort(compareDirEntries);
520
+ } catch {
521
+ return [];
522
+ }
523
+ }
524
+
525
+ function buildVideoTreeRows(rootDir, recursive) {
526
+ const root = path.resolve(rootDir);
527
+ const filesInOrder = [];
528
+
529
+ function walk(dir, depth, name) {
530
+ const dirFiles = [];
531
+ const childRows = [];
532
+
533
+ for (const entry of safeReadDir(dir)) {
534
+ const full = path.join(dir, entry.name);
535
+ if (entry.isDirectory()) {
536
+ if (!recursive) continue;
537
+ const child = walk(full, depth + 1, entry.name);
538
+ if (child.files.length === 0) continue;
539
+ dirFiles.push(...child.files);
540
+ childRows.push(...child.rows);
541
+ } else if (entry.isFile() && isVideo(full)) {
542
+ const absPath = path.resolve(full);
543
+ const sizeBytes = getFileSizeBytes(absPath) ?? 0;
544
+ dirFiles.push(absPath);
545
+ filesInOrder.push(absPath);
546
+ childRows.push({
547
+ type: "file",
548
+ path: absPath,
549
+ name: entry.name,
550
+ depth: depth + 1,
551
+ sizeBytes,
552
+ });
553
+ }
554
+ }
555
+
556
+ if (dirFiles.length === 0) return { rows: [], files: [] };
557
+ return {
558
+ files: dirFiles,
559
+ rows: [{
560
+ type: "dir",
561
+ path: path.resolve(dir),
562
+ name,
563
+ depth,
564
+ filePaths: dirFiles,
565
+ }, ...childRows],
566
+ };
567
+ }
568
+
569
+ const rootName = path.basename(root) || root;
570
+ const tree = walk(root, 0, rootName);
571
+ return { rows: tree.rows, files: filesInOrder };
572
+ }
573
+
574
+ function selectedCountForRow(row, selectedSet) {
575
+ if (row.type === "file") return selectedSet.has(row.path) ? 1 : 0;
576
+ return row.filePaths.reduce((sum, file) => sum + (selectedSet.has(file) ? 1 : 0), 0);
577
+ }
578
+
579
+ function markForRow(row, selectedSet) {
580
+ const selected = selectedCountForRow(row, selectedSet);
581
+ const total = row.type === "file" ? 1 : row.filePaths.length;
582
+ if (selected === 0) return "[ ]";
583
+ if (selected === total) return "[x]";
584
+ return "[-]";
585
+ }
586
+
587
+ function togglePickRow(row, selectedSet) {
588
+ const targets = row.type === "file" ? [row.path] : row.filePaths;
589
+ const allSelected = targets.every((file) => selectedSet.has(file));
590
+ for (const file of targets) {
591
+ if (allSelected) selectedSet.delete(file);
592
+ else selectedSet.add(file);
593
+ }
594
+ }
595
+
596
+ function clipText(text, width) {
597
+ if (!width || width <= 0 || text.length <= width) return text;
598
+ if (width <= 3) return text.slice(0, width);
599
+ return text.slice(0, width - 3) + "...";
600
+ }
332
601
 
333
- function getCompressedVideoPath(originalPath) {
602
+ function renderPickTree({ rows, cursor, selectedSet, rootDir, recursive, totalFiles }) {
603
+ const height = process.stdout.rows || 24;
604
+ const width = process.stdout.columns || 100;
605
+ const visibleCount = Math.max(6, height - 7);
606
+ const start = Math.min(
607
+ Math.max(0, cursor - Math.floor(visibleCount / 2)),
608
+ Math.max(0, rows.length - visibleCount)
609
+ );
610
+ const visibleRows = rows.slice(start, start + visibleCount);
611
+
612
+ process.stdout.write("\x1b[H\x1b[2J");
613
+ console.log("选择需要压缩的视频文件");
614
+ console.log(clipText(`目录:${rootDir}`, width));
615
+ console.log(`递归:${recursive ? "是" : "否"} | 已选:${selectedSet.size}/${totalFiles}`);
616
+ console.log("↑/↓ 或 j/k 移动 Space 选择 a 全选/清空 Enter 开始压缩 q 退出");
617
+ console.log("─".repeat(Math.max(20, Math.min(width, 100))));
618
+
619
+ for (let i = 0; i < visibleRows.length; i++) {
620
+ const rowIndex = start + i;
621
+ const row = visibleRows[i];
622
+ const pointer = rowIndex === cursor ? ">" : " ";
623
+ const indent = " ".repeat(row.depth);
624
+ const mark = markForRow(row, selectedSet);
625
+ const label = row.type === "dir"
626
+ ? `${row.name}/ (${selectedCountForRow(row, selectedSet)}/${row.filePaths.length})`
627
+ : `${row.name} ${fmtSize(row.sizeBytes)}`;
628
+ const icon = row.type === "dir" ? "▸ " : " ";
629
+ console.log(clipText(`${pointer} ${mark} ${indent}${icon}${label}`, width));
630
+ }
631
+
632
+ if (start + visibleCount < rows.length) {
633
+ console.log(clipText(` ... 还有 ${rows.length - start - visibleCount} 行`, width));
634
+ }
635
+ }
636
+
637
+ function readInputChunk() {
638
+ return new Promise((resolve) => process.stdin.once("data", resolve));
639
+ }
640
+
641
+ async function pickVideoFilesFromTree({ rows, files, rootDir, recursive }) {
642
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
643
+ console.log("⚠️ 非交互终端,默认选择全部视频。");
644
+ return files;
645
+ }
646
+
647
+ const selectedSet = new Set();
648
+ let cursor = 0;
649
+
650
+ process.stdin.setRawMode(true);
651
+ process.stdin.resume();
652
+ process.stdout.write("\x1b[?25l");
653
+
654
+ try {
655
+ while (true) {
656
+ renderPickTree({ rows, cursor, selectedSet, rootDir, recursive, totalFiles: files.length });
657
+ const key = (await readInputChunk()).toString("utf8");
658
+
659
+ if (key === "\u0003" || key.toLowerCase() === "q") {
660
+ return null;
661
+ }
662
+ if (key === "\x1b[A" || key.toLowerCase() === "k") {
663
+ cursor = Math.max(0, cursor - 1);
664
+ } else if (key === "\x1b[B" || key.toLowerCase() === "j") {
665
+ cursor = Math.min(rows.length - 1, cursor + 1);
666
+ } else if (key === " ") {
667
+ togglePickRow(rows[cursor], selectedSet);
668
+ } else if (key.toLowerCase() === "a") {
669
+ if (selectedSet.size === files.length) selectedSet.clear();
670
+ else files.forEach((file) => selectedSet.add(file));
671
+ } else if (key === "\r" || key === "\n") {
672
+ return files.filter((file) => selectedSet.has(file));
673
+ }
674
+ }
675
+ } finally {
676
+ process.stdin.setRawMode(false);
677
+ process.stdin.pause();
678
+ process.stdout.write("\x1b[?25h\x1b[H\x1b[2J");
679
+ }
680
+ }
681
+
682
+ function buildSelectedVideoList(files, rootDir) {
683
+ const list = [];
684
+ for (let i = 0; i < files.length; i++) {
685
+ const file = files[i];
686
+ process.stdout.write(` 读取码率 [${i + 1}/${files.length}]:${path.basename(file)} ... `);
687
+ const kbps = getVideoBitrate(file);
688
+ const size = getFileSizeBytes(file) ?? 0;
689
+ console.log(kbps === null ? "⚠️ 无法读取" : `${kbps} kbps`);
690
+ list.push({
691
+ path: path.resolve(file),
692
+ relative_path: path.relative(rootDir, file),
693
+ bitrate_kbps: kbps,
694
+ size_mb: parseFloat((size / 1e6).toFixed(2)),
695
+ });
696
+ }
697
+ return list;
698
+ }
699
+
700
+ async function runPick(args, rest, cliArgs = {}) {
701
+ const positionals = parsePositionals(rest);
702
+ const dir = path.resolve(args.dir || positionals[0] || process.cwd());
703
+
704
+ if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
705
+ console.error(`❌ 目录不存在:${dir}`);
706
+ process.exit(1);
707
+ }
708
+
709
+ const recursive = cliArgs.recursive === undefined
710
+ ? await askYesNo("是否递归遍历子文件夹", asBool(args.recursive))
711
+ : asBool(args.recursive);
712
+
713
+ console.log(`\n🔎 扫描视频文件:${dir}`);
714
+ console.log(`📁 递归:${recursive ? "是" : "否"}\n`);
715
+
716
+ const { rows, files } = buildVideoTreeRows(dir, recursive);
717
+ if (files.length === 0) {
718
+ console.log("⚠️ 未找到可压缩的视频文件。");
719
+ return;
720
+ }
721
+
722
+ const selectedFiles = await pickVideoFilesFromTree({ rows, files, rootDir: dir, recursive });
723
+ if (selectedFiles === null) {
724
+ console.log("已取消。");
725
+ return;
726
+ }
727
+ if (selectedFiles.length === 0) {
728
+ console.log("未选择文件,流程结束。");
729
+ return;
730
+ }
731
+
732
+ console.log(`已选择 ${selectedFiles.length} 个视频,准备压缩:\n`);
733
+ const list = buildSelectedVideoList(selectedFiles, dir);
734
+ const listFile = args.output || path.join(os.tmpdir(), `vidcom_pick_${Date.now()}.json`);
735
+ const cleanList = !args.output;
736
+ fs.writeFileSync(listFile, JSON.stringify(list, null, 2), "utf8");
737
+ if (!cleanList) console.log(`\n💾 已保存选择列表:${path.resolve(listFile)}`);
738
+
739
+ try {
740
+ await runCompress({ ...args, input: listFile });
741
+ } finally {
742
+ if (cleanList && fs.existsSync(listFile)) fs.unlinkSync(listFile);
743
+ }
744
+ }
745
+
746
+ // ─── Mode 4: delete ───────────────────────────────────────────────────────────
747
+
748
+ function getCompressedVideoPath(originalPath, suffix = "_compressed") {
334
749
  const dir = path.dirname(originalPath);
335
750
  const ext = path.extname(originalPath);
336
751
  const base = path.basename(originalPath, ext);
752
+ const safeSuffix = suffix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
753
+ const safeExt = ext ? ext.slice(1).replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : "[mM][pP]4";
337
754
  const pattern = new RegExp(
338
- "^" + base.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "_compressed\\.[mM][pP]4$"
755
+ "^" + base.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + safeSuffix + "\\." + safeExt + "$",
756
+ "i"
339
757
  );
340
758
  try {
341
759
  const match = fs.readdirSync(dir).find((f) => pattern.test(f));
342
- return match ? path.join(dir, match) : path.join(dir, base + "_compressed.MP4");
760
+ return match ? path.join(dir, match) : path.join(dir, base + suffix + ext);
343
761
  } catch {
344
- return path.join(dir, base + "_compressed.MP4");
762
+ return path.join(dir, base + suffix + ext);
345
763
  }
346
764
  }
347
765
 
348
766
  function getVideoMeta(filePath) {
349
767
  try {
350
- const raw = execSync(
351
- `ffprobe -v error \
352
- -show_entries format_tags=creation_time \
353
- -show_entries format=duration \
354
- -of json "${filePath}"`,
355
- { stdio: ["pipe", "pipe", "pipe"] }
356
- ).toString().trim();
768
+ const raw = execOutput("ffprobe", [
769
+ "-v", "error",
770
+ "-show_entries", "format_tags=creation_time",
771
+ "-show_entries", "format=duration",
772
+ "-of", "json",
773
+ filePath,
774
+ ]).trim();
357
775
  const obj = JSON.parse(raw);
358
776
  return {
359
777
  creation_time: obj.format?.tags?.creation_time || null,
@@ -399,6 +817,7 @@ function checkVideoSourceMatch(origPath, compPath) {
399
817
  async function runDelete(args, rest) {
400
818
  const positional = rest.find((a) => !a.startsWith("-"));
401
819
  const jsonFile = args.input || positional || path.join(__dirname, "my_list.json");
820
+ const suffix = args.suffix || "_compressed";
402
821
 
403
822
  if (!fs.existsSync(jsonFile)) {
404
823
  console.error(`File not found: ${jsonFile}`);
@@ -417,7 +836,7 @@ async function runDelete(args, rest) {
417
836
  for (let i = 0; i < list.length; i++) {
418
837
  const item = list[i];
419
838
  const origPath = item.path;
420
- const compPath = getCompressedVideoPath(origPath);
839
+ const compPath = getCompressedVideoPath(origPath, suffix);
421
840
 
422
841
  console.log(`[${i + 1}/${list.length}]`);
423
842
  console.log(` Original : ${origPath}`);
@@ -469,7 +888,53 @@ async function runDelete(args, rest) {
469
888
  console.log(`Deleted: ${deleted} | Skipped: ${skipped} | No compressed: ${noCompressed} | Mismatch: ${mismatch}`);
470
889
  }
471
890
 
472
- // ─── Mode 4: arw-convert ─────────────────────────────────────────────────────
891
+ // ─── Mode 5: findComDel ──────────────────────────────────────────────────────
892
+
893
+ /**
894
+ * findComDel:一键完成 find → compress → delete 三步流程。
895
+ * 参数与 find/compress 相同;中间 JSON 列表默认写到临时文件,
896
+ * 也可用 --output 指定。
897
+ */
898
+ async function runFindComDel(args) {
899
+ // 临时列表文件(若用户未指定则用临时路径)
900
+ const tmpList = args.output || path.join(
901
+ os.tmpdir(),
902
+ `vidcom_${Date.now()}.json`
903
+ );
904
+ const cleanTmp = !args.output; // 未指定时结束后删除
905
+
906
+ // ── Step 1: find ──
907
+ console.log("\n════════════════════════════════════════");
908
+ console.log(" Step 1 / 3 — find 扫描高码率视频");
909
+ console.log("════════════════════════════════════════");
910
+ runFind({ ...args, output: tmpList });
911
+
912
+ // 检查是否有可压缩文件
913
+ let list = [];
914
+ try { list = JSON.parse(fs.readFileSync(tmpList, "utf8")); } catch { /* empty */ }
915
+ if (!Array.isArray(list) || list.length === 0) {
916
+ console.log("\n✅ 没有超标视频,流程结束。");
917
+ if (cleanTmp && fs.existsSync(tmpList)) fs.unlinkSync(tmpList);
918
+ return;
919
+ }
920
+
921
+ // ── Step 2: compress ──
922
+ console.log("\n════════════════════════════════════════");
923
+ console.log(" Step 2 / 3 — compress 批量压缩");
924
+ console.log("════════════════════════════════════════");
925
+ await runCompress({ ...args, input: tmpList });
926
+
927
+ // ── Step 3: delete ──
928
+ console.log("\n════════════════════════════════════════");
929
+ console.log(" Step 3 / 3 — delete 删除原始文件");
930
+ console.log("════════════════════════════════════════");
931
+ await runDelete({ ...args, input: tmpList }, []);
932
+
933
+ if (cleanTmp && fs.existsSync(tmpList)) fs.unlinkSync(tmpList);
934
+ console.log("\n🏁 findComDel 全部完成。");
935
+ }
936
+
937
+ // ─── Mode 6: arw-convert ─────────────────────────────────────────────────────
473
938
 
474
939
  function runArwConvert(positionals) {
475
940
  const srcDir = positionals[0];
@@ -485,14 +950,11 @@ function runArwConvert(positionals) {
485
950
  if (result.status !== 0) process.exit(result.status || 1);
486
951
  }
487
952
 
488
- // ─── Mode 5: arw-delete ──────────────────────────────────────────────────────
953
+ // ─── Mode 7: arw-delete ──────────────────────────────────────────────────────
489
954
 
490
955
  function getImageDimensions(filePath) {
491
956
  try {
492
- const out = execSync(
493
- `sips -g pixelWidth -g pixelHeight "${filePath}"`,
494
- { stdio: ["pipe", "pipe", "pipe"] }
495
- ).toString();
957
+ const out = execOutput("sips", ["-g", "pixelWidth", "-g", "pixelHeight", filePath]);
496
958
  const w = out.match(/pixelWidth:\s*(\d+)/)?.[1];
497
959
  const h = out.match(/pixelHeight:\s*(\d+)/)?.[1];
498
960
  if (w && h) return { width: parseInt(w, 10), height: parseInt(h, 10) };
@@ -624,13 +1086,32 @@ Commands / 命令:
624
1086
  扫描目录,找出高码率视频,输出 JSON 列表
625
1087
  compress Batch compress using FFmpeg hardware acceleration
626
1088
  读取 JSON 列表,FFmpeg 硬件加速批量压缩
1089
+ pick Interactively select videos from a folder tree, then compress
1090
+ 树形交互选择视频(↑/↓ 移动,空格选择,回车压缩)
627
1091
  delete Verify compressed files then delete originals
628
1092
  校验压缩文件后删除原始视频
1093
+ findComDel find + compress + delete in one step (典型一键流程)
629
1094
  arw-convert Convert ARW RAW files to JPEG (parallel, macOS sips)
630
1095
  批量转换 ARW 为 JPEG(多线程,macOS sips)
631
1096
  arw-delete Verify JPEG exists then delete ARW originals
632
1097
  校验 JPEG 后删除 ARW 原始文件
633
1098
 
1099
+ ─────────────────────────────────────────────────────────────────────────────
1100
+ ~/.vidComRc 配置文件(JSON):
1101
+
1102
+ 可设置以下字段作为默认值,CLI 参数优先级更高:
1103
+ {
1104
+ "threshold": 4000, // find 码率阈值(kbps)
1105
+ "recursive": true, // find 是否递归
1106
+ "outdir": "/path/to/out", // compress 输出目录
1107
+ "suffix": "_compressed", // 输出文件名后缀
1108
+ "quality": 65, // compress quality 值(0-100)
1109
+ "target": 2000, // compress 固定目标码率(kbps,覆盖 quality)
1110
+ "audio": 128, // 音频码率(kbps)
1111
+ "hdr": true, // 输出 HDR10 10-bit HEVC + BT.2020/PQ 标记
1112
+ "lut": "/path/look.cube" // 应用 .cube LUT
1113
+ }
1114
+
634
1115
  ─────────────────────────────────────────────────────────────────────────────
635
1116
  find options:
636
1117
 
@@ -648,19 +1129,51 @@ compress options:
648
1129
  -q/--quality <0-100> Quality value, higher is better, default 65
649
1130
  --target <kbps> Fixed bitrate mode, overrides -q
650
1131
  --audio <kbps> Audio bitrate, default 128
1132
+ --hdr Output HDR10 10-bit HEVC with BT.2020/PQ metadata
1133
+ --lut <file.cube> Apply a 3D LUT before encoding
651
1134
 
652
1135
  Encoder:hevc_videotoolbox (Apple hardware H.265, macOS only)
653
1136
 
1137
+ ─────────────────────────────────────────────────────────────────────────────
1138
+ pick options:(交互选择 + compress 同参数)
1139
+
1140
+ --dir <path> Directory to scan, default: current directory
1141
+ --recursive Recurse into subdirectories; omit to ask interactively
1142
+ --no-recursive Do not recurse, useful for scripts
1143
+ --output <file> Keep the selected JSON list instead of temp-only
1144
+ --outdir <dir> Compressed output directory
1145
+ --suffix <suffix> Filename suffix, default _compressed
1146
+ -q/--quality <0-100> Quality value
1147
+ --target <kbps> Fixed bitrate mode
1148
+ --audio <kbps> Audio bitrate
1149
+ --hdr Output HDR10 10-bit HEVC with BT.2020/PQ metadata
1150
+ --lut <file> Apply a .cube LUT before encoding
1151
+
654
1152
  ─────────────────────────────────────────────────────────────────────────────
655
1153
  delete options:
656
1154
 
657
1155
  --input <json> JSON list from find (default: my_list.json)
658
1156
 
1157
+ ─────────────────────────────────────────────────────────────────────────────
1158
+ findComDel options:(find + compress + delete 同参数)
1159
+
1160
+ --dir <path> Required. Directory to scan
1161
+ --threshold <kbps> Bitrate threshold (default from ~/.vidComRc or 4000)
1162
+ --recursive Recurse into subdirectories
1163
+ --outdir <dir> Compressed output directory
1164
+ --suffix <suffix> Filename suffix (default _compressed)
1165
+ -q/--quality <0-100> Quality value
1166
+ --target <kbps> Fixed bitrate mode
1167
+ --audio <kbps> Audio bitrate
1168
+ --hdr Output HDR10 10-bit HEVC with BT.2020/PQ metadata
1169
+ --lut <file> Apply a .cube LUT before encoding
1170
+ --output <file> Intermediate JSON list path (default: temp file)
1171
+
659
1172
  ─────────────────────────────────────────────────────────────────────────────
660
1173
  arw-convert usage:
661
1174
 
662
1175
  vid-com arw-convert <源目录> [输出目录]
663
- 输出目录默认为 ./converted_jpgs,质量 90,多线程并发
1176
+ 不指定输出目录时 JPG 与源文件同级;指定时保留子目录结构输出到目标目录。质量 90,多线程并发
664
1177
 
665
1178
  ─────────────────────────────────────────────────────────────────────────────
666
1179
  arw-delete options:
@@ -670,10 +1183,21 @@ arw-delete options:
670
1183
  ─────────────────────────────────────────────────────────────────────────────
671
1184
  Video pipeline / 视频工作流:
672
1185
 
1186
+ # 分步
673
1187
  vid-com find --dir ~/Videos --recursive --output /tmp/list.json
674
1188
  vid-com compress --input /tmp/list.json --outdir ~/Videos/out
675
1189
  vid-com delete --input /tmp/list.json
676
1190
 
1191
+ # HDR + LUT
1192
+ vid-com compress --input /tmp/list.json --outdir ~/Videos/out --hdr --lut ~/Looks/film.cube --suffix _hdr_lut
1193
+
1194
+ # 一键
1195
+ vid-com findComDel --dir ~/Videos --recursive
1196
+
1197
+ # 交互式选择
1198
+ cd ~/Videos
1199
+ vid-com pick
1200
+
677
1201
  ARW pipeline / ARW 工作流:
678
1202
 
679
1203
  vid-com arw-convert /Volumes/Untitled/DCIM /Volumes/QWER/out
@@ -686,7 +1210,9 @@ ARW pipeline / ARW 工作流:
686
1210
  // ─── 入口 ────────────────────────────────────────────────────────────────────
687
1211
 
688
1212
  const [,, mode, ...rest] = process.argv;
689
- const args = parseArgs(rest);
1213
+ const config = loadConfig();
1214
+ const cliArgs = parseArgs(rest);
1215
+ const args = mergeConfig(cliArgs, config);
690
1216
 
691
1217
  if (!mode || mode === "--help" || mode === "-h") {
692
1218
  printHelp();
@@ -694,15 +1220,19 @@ if (!mode || mode === "--help" || mode === "-h") {
694
1220
  runFind(args);
695
1221
  } else if (mode === "compress") {
696
1222
  runCompress(args).catch((err) => { console.error(err); process.exit(1); });
1223
+ } else if (mode === "pick" || mode === "interactive") {
1224
+ runPick(args, rest, cliArgs).catch((err) => { console.error(err); process.exit(1); });
697
1225
  } else if (mode === "delete") {
698
1226
  runDelete(args, rest).catch((err) => { console.error(err); process.exit(1); });
1227
+ } else if (mode === "findComDel") {
1228
+ runFindComDel(args).catch((err) => { console.error(err); process.exit(1); });
699
1229
  } else if (mode === "arw-convert") {
700
1230
  const positionals = rest.filter((a) => !a.startsWith("-"));
701
1231
  runArwConvert(positionals);
702
1232
  } else if (mode === "arw-delete") {
703
1233
  runArwDelete(args).catch((err) => { console.error(err); process.exit(1); });
704
1234
  } else {
705
- console.error(`❌ 未知命令:${mode}(可用:find / compress / delete / arw-convert / arw-delete)`);
1235
+ console.error(`❌ 未知命令:${mode}(可用:find / compress / pick / delete / findComDel / arw-convert / arw-delete)`);
706
1236
  printHelp();
707
1237
  process.exit(1);
708
- }
1238
+ }