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 +41 -0
- package/package.json +1 -1
- package/scripts/arw_to_jpg.sh +56 -18
- package/video_bitrate_tool.js +578 -48
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
package/scripts/arw_to_jpg.sh
CHANGED
|
@@ -1,13 +1,23 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
|
|
3
3
|
# 1. 处理输入参数
|
|
4
|
-
|
|
5
|
-
|
|
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="$
|
|
10
|
-
OUTPUT_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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
90
|
+
if [ -n "$OUTPUT_DIR" ]; then
|
|
91
|
+
echo "转换完成!所有文件已输出到: $OUTPUT_DIR"
|
|
92
|
+
else
|
|
93
|
+
echo "转换完成!JPG 文件已输出到各自源文件所在目录。"
|
|
94
|
+
fi
|
package/video_bitrate_tool.js
CHANGED
|
@@ -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 {
|
|
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 =
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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 =
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
93
|
-
|
|
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
|
|
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 =
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
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", "
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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, "\\$&") + "
|
|
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 +
|
|
760
|
+
return match ? path.join(dir, match) : path.join(dir, base + suffix + ext);
|
|
343
761
|
} catch {
|
|
344
|
-
return path.join(dir, base +
|
|
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 =
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
).
|
|
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
|
|
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
|
|
953
|
+
// ─── Mode 7: arw-delete ──────────────────────────────────────────────────────
|
|
489
954
|
|
|
490
955
|
function getImageDimensions(filePath) {
|
|
491
956
|
try {
|
|
492
|
-
const out =
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|