vid-com 1.0.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/README.md ADDED
@@ -0,0 +1,228 @@
1
+ # vid-com
2
+
3
+ Video bitrate scanner & batch compressor CLI, with Sony ARW → JPEG conversion and safe interactive cleanup. Powered by FFmpeg with Apple hardware acceleration (hevc_videotoolbox).
4
+
5
+ 视频码率扫描 & 批量压缩 CLI 工具,含 Sony ARW → JPEG 转换及安全交互式清理。基于 FFmpeg,使用 Apple 硬件加速(hevc_videotoolbox)。
6
+
7
+ ## Requirements / 依赖
8
+
9
+ - Node.js >= 16
10
+ - `ffmpeg` / `ffprobe` in PATH
11
+ - macOS (VideoToolbox hardware acceleration)
12
+
13
+ ```bash
14
+ brew install ffmpeg
15
+ ```
16
+
17
+ ## Installation / 安装
18
+
19
+ ```bash
20
+ # Global install (recommended) / 全局安装(推荐)
21
+ npm install -g media-compress
22
+
23
+ # Or run directly / 或直接运行
24
+ node video_bitrate_tool.js <command>
25
+ ```
26
+
27
+ ## Commands / 命令
28
+
29
+ ### find — Scan for high-bitrate videos / 扫描高码率视频
30
+
31
+ Scans a directory for videos exceeding a bitrate threshold and outputs a JSON list sorted by file size.
32
+
33
+ 扫描目录,找出码率超过阈值的视频,输出 JSON 列表,按文件大小升序排列。
34
+
35
+ ```bash
36
+ vid-com find --dir <dir> [options]
37
+ ```
38
+
39
+ | Option | Description | Default |
40
+ |--------|-------------|---------|
41
+ | `--dir` | Directory to scan (required) / 扫描目录(必填) | — |
42
+ | `--threshold` | Bitrate threshold in kbps / 码率阈值(kbps) | `4000` |
43
+ | `--output` | Output JSON file path / 输出 JSON 文件路径 | `bitrate_list.json` |
44
+ | `--recursive` | Recurse into subdirectories / 递归扫描子目录 | off |
45
+
46
+ ```bash
47
+ vid-com find --dir ~/Videos --threshold 3000 --recursive --output dji.json
48
+ ```
49
+
50
+ Output JSON format / 输出 JSON 格式:
51
+ ```json
52
+ [
53
+ {
54
+ "path": "/absolute/path/to/video.mp4",
55
+ "bitrate_kbps": 12800,
56
+ "size_mb": 3200.00
57
+ }
58
+ ]
59
+ ```
60
+
61
+ ---
62
+
63
+ ### compress — Batch compress / 批量压缩
64
+
65
+ Reads the JSON list from `find`, compresses files sequentially with a real-time progress bar.
66
+
67
+ 读取 `find` 输出的 JSON 列表,串行压缩,实时显示进度条。
68
+
69
+ ```bash
70
+ vid-com compress --input <json-file> [options]
71
+ ```
72
+
73
+ | Option | Description | Default |
74
+ |--------|-------------|---------|
75
+ | `--input` | JSON list file (required) / JSON 列表文件(必填) | — |
76
+ | `--outdir` | Output directory / 输出目录 | same as source / 原文件同目录 |
77
+ | `--suffix` | Output filename suffix / 文件名后缀 | `_compressed` |
78
+ | `-q` / `--quality` | Quality value 0–100, higher is better / 画质值(越高越好) | `65` |
79
+ | `--target` | Fixed bitrate in kbps, overrides `-q` / 固定码率,指定后忽略 `-q` | — |
80
+ | `--audio` | Audio bitrate in kbps / 音频码率(kbps) | `128` |
81
+
82
+ **Encoder notes / 编码说明:**
83
+ - Encoder / 编码器: `hevc_videotoolbox` (Apple hardware H.265)
84
+ - Decoder / 解码器: `videotoolbox` (hardware decode, lower CPU load)
85
+ - Pixel format / 像素格式: `nv12` (native VideoToolbox format)
86
+ - Codec tag / codec tag: `hvc1` (QuickTime / Final Cut / iOS compatible)
87
+
88
+ ```bash
89
+ # Quality mode (default, recommended) / 画质模式(默认,推荐)
90
+ vid-com compress --input dji.json --outdir ./out
91
+
92
+ # Fixed bitrate mode / 固定码率模式
93
+ vid-com compress --input dji.json --target 8000 --outdir ./out
94
+
95
+ # Custom quality and audio bitrate / 自定义画质和音频码率
96
+ vid-com compress --input dji.json -q 50 --outdir ./out --suffix _h265 --audio 192
97
+ ```
98
+
99
+ Progress bar example / 进度条示例:
100
+ ```
101
+ ▶ [3/79] DJI_20260328141509_0169_D.MP4
102
+ Source bitrate: 110001 kbps → Quality -q:v 65
103
+ Output: /Volumes/QWER/out/DJI_20260328141509_0169_D_compressed.MP4
104
+ [████████████░░░░░░░░░░░░░░░░░░] 42.3% 3.2x ETA 87s
105
+ ```
106
+
107
+ ---
108
+
109
+ ### delete — Verify and delete original videos / 校验后删除原始视频
110
+
111
+ Reads the `find` JSON list, locates the `_compressed.MP4` counterpart in the same directory, verifies source metadata via `ffprobe`, then prompts for deletion.
112
+
113
+ 读取 JSON 列表,查找同目录下的 `_compressed.MP4`,通过 `ffprobe` 验证源信息后询问删除。
114
+
115
+ ```bash
116
+ vid-com delete [--input <json-file>]
117
+ ```
118
+
119
+ | Option | Description | Default |
120
+ |--------|-------------|---------|
121
+ | `--input` | JSON list from `find` / `find` 输出的 JSON | `my_list.json` |
122
+
123
+ Source metadata checks / 源信息对比(`ffprobe`):
124
+ - **Duration / 时长**: difference must be ≤ 1 second / 差值须 ≤ 1 秒
125
+ - **Capture time / 拍摄时间**: `creation_time` must match exactly (preserved via `-map_metadata 0`)
126
+ - Any mismatch → auto-skipped, no deletion prompt / 任一不匹配则自动跳过
127
+
128
+ ```
129
+ [3/79]
130
+ Original : /Volumes/QWER/DJI_0169.MP4
131
+ Orig size : 3200.00 MB
132
+ Compressed: /Volumes/QWER/DJI_0169_compressed.MP4
133
+ Comp size : 800.00 MB
134
+ Saved : 2400.00 MB (75.0%)
135
+ Duration : 42.3s → 42.3s ✓
136
+ Captured : 2026-03-28T14:15:09.000000Z ✓
137
+ Delete original? [y = yes / other = skip]: y
138
+ -> DELETED
139
+ ```
140
+
141
+ ---
142
+
143
+ ### arw-convert — Sony ARW to JPEG / Sony ARW 转 JPEG
144
+
145
+ Batch-converts Sony `.arw` RAW files to JPEG using macOS built-in `sips` with multi-threaded parallel processing.
146
+
147
+ 批量将索尼 `.arw` RAW 文件转换为 JPEG,使用 macOS 内置 `sips` 多线程并发处理。
148
+
149
+ ```bash
150
+ vid-com arw-convert <source-dir> [output-dir]
151
+ ```
152
+
153
+ | Argument | Description | Default |
154
+ |----------|-------------|---------|
155
+ | `<source-dir>` | Directory containing `.arw` files, searched recursively / 含 `.arw` 文件的目录(递归查找) | required |
156
+ | `[output-dir]` | JPEG output directory, flat (no subdirectory structure) / JPEG 输出目录(平级输出) | `./converted_jpgs` |
157
+
158
+ - Concurrency / 并发数: CPU cores − 1
159
+ - Output quality / 输出质量: JPEG quality 90
160
+ - Dependency / 依赖: macOS built-in `sips`, no extra install needed / macOS 内置,无需额外安装
161
+
162
+ ```bash
163
+ vid-com arw-convert /Volumes/Untitled/DCIM /Volumes/QWER/out
164
+ ```
165
+
166
+ ---
167
+
168
+ ### arw-delete — Verify and delete ARW originals / 校验后删除 ARW 原始文件
169
+
170
+ Scans a directory for `.arw` files, finds a same-named `.jpg`, verifies pixel dimensions via `sips`, then prompts for deletion of the ARW.
171
+
172
+ 扫描目录下所有 `.arw` 文件,查找同名 `.jpg`,通过 `sips` 验证像素尺寸后询问删除 ARW。
173
+
174
+ ```bash
175
+ vid-com arw-delete --dir <dir>
176
+ ```
177
+
178
+ | Option | Description |
179
+ |--------|-------------|
180
+ | `--dir` | Directory containing `.arw` files (required) / 含 `.arw` 文件的目录(必填) |
181
+
182
+ Source metadata check / 源信息对比(`sips`):
183
+ - **Pixel dimensions / 像素尺寸**: width × height must match exactly / 必须完全一致
184
+ - Missing JPG or dimension mismatch → auto-skipped / JPG 不存在或尺寸不匹配则自动跳过
185
+
186
+ ```
187
+ [12/80]
188
+ ARW : DSC00169.ARW
189
+ Size : 24.50 MB
190
+ JPG : DSC00169.jpg (8.20 MB)
191
+ Dims : 7952×5304 vs 7952×5304 ✓
192
+ Delete ARW? [y = yes / other = skip]: y
193
+ -> DELETED
194
+ ```
195
+
196
+ ---
197
+
198
+ ## Typical workflow / 典型工作流
199
+
200
+ ```bash
201
+ # Video workflow / 视频工作流
202
+ vid-com find --dir ~/Videos --recursive --output /tmp/list.json
203
+ vid-com compress --input /tmp/list.json --outdir ~/Videos/out
204
+ vid-com delete --input /tmp/list.json
205
+
206
+ # ARW workflow / ARW 工作流
207
+ vid-com arw-convert /Volumes/Untitled/DCIM /Volumes/QWER/out
208
+ vid-com arw-delete --dir /Volumes/Untitled/DCIM
209
+ ```
210
+
211
+ ---
212
+
213
+ ## Quality reference / 画质参考(-q:v)
214
+
215
+ | `-q` value | Effect / 效果 |
216
+ |------------|---------------|
217
+ | 80–90 | Near-lossless, large file / 接近无损,文件较大 |
218
+ | 60–70 | Balanced quality/size (recommended) / 画质/尺寸均衡(推荐) |
219
+ | 45–55 | Noticeable compression, archive use / 明显压缩,适合纯归档 |
220
+ | < 40 | Visible quality loss / 肉眼可见质量损失 |
221
+
222
+ ---
223
+
224
+ ## Help / 帮助
225
+
226
+ ```bash
227
+ vid-com --help
228
+ ```
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "vid-com",
3
+ "version": "1.0.4",
4
+ "description": "Video bitrate scanner & batch compressor with ARW→JPEG conversion (macOS, FFmpeg, hevc_videotoolbox)",
5
+ "main": "video_bitrate_tool.js",
6
+ "bin": {
7
+ "vid-com": "./video_bitrate_tool.js"
8
+ },
9
+ "scripts": {
10
+ "postinstall": "node scripts/check-deps.js"
11
+ },
12
+ "keywords": [
13
+ "video",
14
+ "ffmpeg",
15
+ "compress",
16
+ "bitrate"
17
+ ],
18
+ "os": [
19
+ "darwin"
20
+ ],
21
+ "license": "MIT",
22
+ "engines": {
23
+ "node": ">=16",
24
+ "ffmpeg": ">=4.0 (install via: brew install ffmpeg)"
25
+ }
26
+ }
@@ -0,0 +1,56 @@
1
+ #!/bin/bash
2
+
3
+ # 1. 处理输入参数
4
+ if [ -z "$1" ]; then
5
+ echo "用法: $0 <源目录> [输出目录]"
6
+ exit 1
7
+ fi
8
+
9
+ SOURCE_DIR="$1"
10
+ OUTPUT_DIR="${2:-./converted_jpgs}"
11
+
12
+ # 2. 检查源目录是否存在
13
+ if [ ! -d "$SOURCE_DIR" ]; then
14
+ echo "错误: 源目录 '$SOURCE_DIR' 不存在。"
15
+ exit 1
16
+ fi
17
+
18
+ mkdir -p "$OUTPUT_DIR"
19
+
20
+ # 3. 计算并发数:当前 CPU 数量 - 1 (最少为 1)
21
+ CPU_CORES=$(sysctl -n hw.ncpu)
22
+ THREADS=$((CPU_CORES > 1 ? CPU_CORES - 1 : 1))
23
+
24
+ echo "------------------------------------------"
25
+ echo "系统 CPU 核心数: $CPU_CORES"
26
+ echo "并发线程数: $THREADS"
27
+ echo "正在开始平级转换..."
28
+ echo "------------------------------------------"
29
+
30
+ # 4. 定义转换函数并导出,以便 xargs 调用
31
+ # 使用 export -f 需要在脚本环境运行
32
+ do_convert() {
33
+ local src_file="$1"
34
+ local out_dir="$2"
35
+
36
+ # 获取纯文件名 (不带路径)
37
+ local filename=$(basename "$src_file")
38
+ # 构造目标路径 (平级输出)
39
+ local target_file="$out_dir/${filename%.*}.jpg"
40
+
41
+ echo "正在转换: $filename"
42
+
43
+ # 执行转换
44
+ sips -s format jpeg -s formatOptions 90 "$src_file" --out "$target_file" > /dev/null 2>&1
45
+ }
46
+
47
+ export -f do_convert
48
+
49
+ # 5. 使用 find + xargs 进行多线程处理
50
+ # -0: 处理带空格的文件名
51
+ # -P: 并发数
52
+ # -I: 占位符
53
+ find "$SOURCE_DIR" -type f -iname "*.arw" -print0 | xargs -0 -I {} -P "$THREADS" bash -c "do_convert '{}' '$OUTPUT_DIR'"
54
+
55
+ echo "------------------------------------------"
56
+ echo "转换完成!所有文件已输出到: $OUTPUT_DIR"
@@ -0,0 +1,347 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * batch_delete.js
5
+ *
6
+ * 用法:
7
+ * node scripts/batch_delete.js [--input <json>] # 视频模式(默认)
8
+ * node scripts/batch_delete.js --mode arw --dir <目录> # ARW 模式
9
+ */
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const readline = require('readline');
14
+ const { execSync } = require('child_process');
15
+
16
+ // ─── 参数解析 ─────────────────────────────────────────────────────────────────
17
+
18
+ function parseArgs(argv) {
19
+ const args = {};
20
+ for (let i = 0; i < argv.length; i++) {
21
+ if (argv[i].startsWith('--')) {
22
+ const key = argv[i].slice(2);
23
+ args[key] = argv[i + 1] && !argv[i + 1].startsWith('-') ? argv[++i] : true;
24
+ }
25
+ }
26
+ return args;
27
+ }
28
+
29
+ const args = parseArgs(process.argv.slice(2));
30
+ const MODE = (args.mode || 'video').toLowerCase();
31
+
32
+ // ─── 共用工具 ─────────────────────────────────────────────────────────────────
33
+
34
+ function formatSize(bytes) {
35
+ return (bytes / 1024 / 1024).toFixed(2) + ' MB';
36
+ }
37
+
38
+ function getFileSizeBytes(filePath) {
39
+ try { return fs.statSync(filePath).size; } catch { return null; }
40
+ }
41
+
42
+ /** 单键确认(TTY),或换行输入(非 TTY) */
43
+ function askKey(question) {
44
+ return new Promise((resolve) => {
45
+ process.stdout.write(question);
46
+ if (process.stdin.isTTY) {
47
+ process.stdin.setRawMode(true);
48
+ process.stdin.resume();
49
+ process.stdin.once('data', (buf) => {
50
+ const key = buf.toString();
51
+ process.stdin.setRawMode(false);
52
+ process.stdin.pause();
53
+ process.stdout.write(key === '\r' || key === '\n' ? '\n' : key + '\n');
54
+ resolve(key);
55
+ });
56
+ } else {
57
+ const rl = readline.createInterface({ input: process.stdin, terminal: false });
58
+ rl.once('line', (line) => { rl.close(); resolve(line.trim()); });
59
+ }
60
+ });
61
+ }
62
+
63
+ // ─── 视频模式 ─────────────────────────────────────────────────────────────────
64
+
65
+ /** 在同目录下找 *_compressed.MP4(大小写不敏感) */
66
+ function getCompressedVideoPath(originalPath) {
67
+ const dir = path.dirname(originalPath);
68
+ const ext = path.extname(originalPath);
69
+ const base = path.basename(originalPath, ext);
70
+ const pattern = new RegExp(
71
+ '^' + base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '_compressed\\.[mM][pP]4$'
72
+ );
73
+ try {
74
+ const match = fs.readdirSync(dir).find((f) => pattern.test(f));
75
+ return match ? path.join(dir, match) : path.join(dir, base + '_compressed.MP4');
76
+ } catch {
77
+ return path.join(dir, base + '_compressed.MP4');
78
+ }
79
+ }
80
+
81
+ /** 用 ffprobe 读取视频元信息:creation_time + duration */
82
+ function getVideoMeta(filePath) {
83
+ try {
84
+ const raw = execSync(
85
+ `ffprobe -v error \
86
+ -show_entries format_tags=creation_time \
87
+ -show_entries format=duration \
88
+ -of json "${filePath}"`,
89
+ { stdio: ['pipe', 'pipe', 'pipe'] }
90
+ ).toString().trim();
91
+ const obj = JSON.parse(raw);
92
+ return {
93
+ creation_time: obj.format?.tags?.creation_time || null,
94
+ duration: parseFloat(obj.format?.duration) || null,
95
+ };
96
+ } catch {
97
+ return null;
98
+ }
99
+ }
100
+
101
+ /**
102
+ * 对比原始视频与压缩视频的源信息
103
+ * @returns {{ match: boolean|null, lines: string[] }}
104
+ */
105
+ function checkVideoSourceMatch(origPath, compPath) {
106
+ const origMeta = getVideoMeta(origPath);
107
+ const compMeta = getVideoMeta(compPath);
108
+ const lines = [];
109
+
110
+ if (!origMeta || !compMeta) {
111
+ return { match: null, lines: [' 源信息 : 无法读取(ffprobe 失败),跳过验证'] };
112
+ }
113
+
114
+ let match = true;
115
+
116
+ // 时长对比(±1s 容差)
117
+ if (origMeta.duration !== null && compMeta.duration !== null) {
118
+ const diff = Math.abs(origMeta.duration - compMeta.duration);
119
+ const ok = diff <= 1.0;
120
+ lines.push(` 时长 : ${origMeta.duration.toFixed(1)}s → ${compMeta.duration.toFixed(1)}s ${ok ? '✓' : '✗ 不匹配'}`);
121
+ if (!ok) match = false;
122
+ }
123
+
124
+ // 拍摄时间对比
125
+ if (origMeta.creation_time && compMeta.creation_time) {
126
+ const ok = origMeta.creation_time === compMeta.creation_time;
127
+ lines.push(` 拍摄时间 : ${origMeta.creation_time} ${ok ? '✓' : '✗ 不匹配'}`);
128
+ if (!ok) match = false;
129
+ }
130
+
131
+ if (lines.length === 0) {
132
+ lines.push(' 源信息 : 无可用元数据,跳过验证');
133
+ return { match: null, lines };
134
+ }
135
+
136
+ return { match, lines };
137
+ }
138
+
139
+ async function runVideoMode(jsonFile) {
140
+ if (!fs.existsSync(jsonFile)) {
141
+ console.error(`File not found: ${jsonFile}`);
142
+ process.exit(1);
143
+ }
144
+
145
+ const list = JSON.parse(fs.readFileSync(jsonFile, 'utf-8'));
146
+ if (!Array.isArray(list) || list.length === 0) {
147
+ console.log('No entries found in JSON.');
148
+ process.exit(0);
149
+ }
150
+
151
+ let deleted = 0, skipped = 0, noCompressed = 0, mismatch = 0;
152
+ console.log(`Processing ${list.length} file(s) from: ${jsonFile}\n`);
153
+
154
+ for (let i = 0; i < list.length; i++) {
155
+ const item = list[i];
156
+ const origPath = item.path;
157
+ const compPath = getCompressedVideoPath(origPath);
158
+
159
+ console.log(`[${i + 1}/${list.length}]`);
160
+ console.log(` Original : ${origPath}`);
161
+
162
+ const origSize = getFileSizeBytes(origPath);
163
+ if (origSize === null) {
164
+ console.log(` Original : NOT FOUND, skipping\n`);
165
+ skipped++;
166
+ continue;
167
+ }
168
+ console.log(` Orig size : ${formatSize(origSize)}`);
169
+
170
+ const compSize = getFileSizeBytes(compPath);
171
+ if (compSize === null) {
172
+ console.log(` Compressed: NOT FOUND (${path.basename(compPath)}), skipping\n`);
173
+ noCompressed++;
174
+ continue;
175
+ }
176
+
177
+ console.log(` Compressed: ${compPath}`);
178
+ console.log(` Comp size : ${formatSize(compSize)}`);
179
+ console.log(` Saved : ${formatSize(origSize - compSize)} (${(((origSize - compSize) / origSize) * 100).toFixed(1)}%)`);
180
+
181
+ // 源信息对比
182
+ const { match, lines } = checkVideoSourceMatch(origPath, compPath);
183
+ lines.forEach((l) => console.log(l));
184
+
185
+ if (match === false) {
186
+ console.log(' -> 源信息不匹配,自动跳过\n');
187
+ mismatch++;
188
+ continue;
189
+ }
190
+
191
+ const key = await askKey(' Delete original? [y = yes / other = skip]: ');
192
+ if (key.toLowerCase() === 'y') {
193
+ try {
194
+ fs.unlinkSync(origPath);
195
+ console.log(' -> DELETED\n');
196
+ deleted++;
197
+ } catch (err) {
198
+ console.error(` -> FAILED: ${err.message}\n`);
199
+ }
200
+ } else {
201
+ console.log(' -> Skipped\n');
202
+ skipped++;
203
+ }
204
+ }
205
+
206
+ console.log('─'.repeat(50));
207
+ console.log(`Deleted: ${deleted} | Skipped: ${skipped} | No compressed: ${noCompressed} | Mismatch: ${mismatch}`);
208
+ }
209
+
210
+ // ─── ARW 模式 ─────────────────────────────────────────────────────────────────
211
+
212
+ /** 用 sips 读取图片尺寸(支持 ARW / JPG) */
213
+ function getImageDimensions(filePath) {
214
+ try {
215
+ const out = execSync(
216
+ `sips -g pixelWidth -g pixelHeight "${filePath}"`,
217
+ { stdio: ['pipe', 'pipe', 'pipe'] }
218
+ ).toString();
219
+ const w = out.match(/pixelWidth:\s*(\d+)/)?.[1];
220
+ const h = out.match(/pixelHeight:\s*(\d+)/)?.[1];
221
+ if (w && h) return { width: parseInt(w, 10), height: parseInt(h, 10) };
222
+ return null;
223
+ } catch {
224
+ return null;
225
+ }
226
+ }
227
+
228
+ /** 在同目录下查找同名的 .jpg / .jpeg(大小写不敏感) */
229
+ function findJpgForArw(arwPath) {
230
+ const dir = path.dirname(arwPath);
231
+ const base = path.basename(arwPath, path.extname(arwPath)).toLowerCase();
232
+ try {
233
+ const match = fs.readdirSync(dir).find((f) => {
234
+ const fExt = path.extname(f).toLowerCase();
235
+ const fBase = path.basename(f, path.extname(f)).toLowerCase();
236
+ return fBase === base && (fExt === '.jpg' || fExt === '.jpeg');
237
+ });
238
+ return match ? path.join(dir, match) : null;
239
+ } catch {
240
+ return null;
241
+ }
242
+ }
243
+
244
+ /** 列出目录下所有 .arw 文件(不递归) */
245
+ function listArwFiles(dir) {
246
+ try {
247
+ return fs.readdirSync(dir, { withFileTypes: true })
248
+ .filter((e) => e.isFile() && path.extname(e.name).toLowerCase() === '.arw')
249
+ .map((e) => path.join(dir, e.name));
250
+ } catch {
251
+ return [];
252
+ }
253
+ }
254
+
255
+ async function runArwMode(dir) {
256
+ const arwFiles = listArwFiles(dir);
257
+ if (arwFiles.length === 0) {
258
+ console.log(`未找到 .arw 文件:${dir}`);
259
+ process.exit(0);
260
+ }
261
+
262
+ let deleted = 0, skipped = 0, noJpg = 0, mismatch = 0;
263
+ console.log(`扫描到 ${arwFiles.length} 个 .arw 文件,目录:${dir}\n`);
264
+
265
+ for (let i = 0; i < arwFiles.length; i++) {
266
+ const arwPath = arwFiles[i];
267
+ const jpgPath = findJpgForArw(arwPath);
268
+
269
+ console.log(`[${i + 1}/${arwFiles.length}]`);
270
+ console.log(` ARW : ${path.basename(arwPath)}`);
271
+
272
+ const arwSize = getFileSizeBytes(arwPath);
273
+ if (arwSize === null) {
274
+ console.log(` ARW : NOT FOUND, skipping\n`);
275
+ skipped++;
276
+ continue;
277
+ }
278
+ console.log(` 大小 : ${formatSize(arwSize)}`);
279
+
280
+ if (!jpgPath) {
281
+ console.log(` JPG : NOT FOUND,跳过\n`);
282
+ noJpg++;
283
+ continue;
284
+ }
285
+
286
+ const jpgSize = getFileSizeBytes(jpgPath);
287
+ console.log(` JPG : ${path.basename(jpgPath)} (${formatSize(jpgSize ?? 0)})`);
288
+
289
+ // 源信息对比:像素尺寸
290
+ const arwDim = getImageDimensions(arwPath);
291
+ const jpgDim = getImageDimensions(jpgPath);
292
+
293
+ if (!arwDim || !jpgDim) {
294
+ console.log(` 尺寸 : 无法读取(sips 失败),跳过\n`);
295
+ mismatch++;
296
+ continue;
297
+ }
298
+
299
+ const dimMatch = arwDim.width === jpgDim.width && arwDim.height === jpgDim.height;
300
+ console.log(
301
+ ` 尺寸 : ${arwDim.width}×${arwDim.height} vs ${jpgDim.width}×${jpgDim.height} ${dimMatch ? '✓' : '✗ 不匹配'}`
302
+ );
303
+
304
+ if (!dimMatch) {
305
+ console.log(` -> 尺寸不匹配,自动跳过\n`);
306
+ mismatch++;
307
+ continue;
308
+ }
309
+
310
+ const key = await askKey(' Delete ARW? [y = yes / other = skip]: ');
311
+ if (key.toLowerCase() === 'y') {
312
+ try {
313
+ fs.unlinkSync(arwPath);
314
+ console.log(' -> DELETED\n');
315
+ deleted++;
316
+ } catch (err) {
317
+ console.error(` -> FAILED: ${err.message}\n`);
318
+ }
319
+ } else {
320
+ console.log(' -> Skipped\n');
321
+ skipped++;
322
+ }
323
+ }
324
+
325
+ console.log('─'.repeat(50));
326
+ console.log(`Deleted: ${deleted} | Skipped: ${skipped} | No JPG: ${noJpg} | Mismatch: ${mismatch}`);
327
+ }
328
+
329
+ // ─── 入口 ─────────────────────────────────────────────────────────────────────
330
+
331
+ if (MODE === 'arw') {
332
+ const dir = args.dir;
333
+ if (!dir) {
334
+ console.error('ARW 模式需要 --dir 指定目录');
335
+ process.exit(1);
336
+ }
337
+ if (!fs.existsSync(dir)) {
338
+ console.error(`目录不存在:${dir}`);
339
+ process.exit(1);
340
+ }
341
+ runArwMode(path.resolve(dir)).catch((err) => { console.error(err); process.exit(1); });
342
+ } else {
343
+ // 兼容旧版位置参数(node batch_delete.js my_list.json)
344
+ const positional = process.argv.slice(2).find((a) => !a.startsWith('-'));
345
+ const jsonFile = args.input || positional || path.join(__dirname, '../my_list.json');
346
+ runVideoMode(jsonFile).catch((err) => { console.error(err); process.exit(1); });
347
+ }
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { execSync } = require("child_process");
4
+
5
+ const deps = ["ffmpeg", "ffprobe"];
6
+ let missing = false;
7
+
8
+ for (const bin of deps) {
9
+ try {
10
+ execSync(`which ${bin}`, { stdio: "ignore" });
11
+ } catch {
12
+ console.warn(`\n⚠️ 未找到 ${bin},请先安装:brew install ffmpeg\n`);
13
+ missing = true;
14
+ }
15
+ }
16
+
17
+ if (missing) process.exit(1);
@@ -0,0 +1,708 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * video_bitrate_tool.js
5
+ *
6
+ * 功能:
7
+ * mode 1 (find) - 扫描目录,找出码率超过阈值的视频,输出 JSON 列表
8
+ * mode 2 (compress) - 读取 JSON 列表,用 FFmpeg 将视频压缩到目标码率
9
+ *
10
+ * 用法:
11
+ * node video_bitrate_tool.js find --dir /path/to/videos --threshold 4000 [--output list.json] [--recursive]
12
+ * node video_bitrate_tool.js compress --input list.json --target 2000 [--outdir /path/to/output] [--suffix _compressed]
13
+ *
14
+ * 依赖:ffprobe / ffmpeg(需已安装并在 PATH 中)
15
+ */
16
+
17
+ const { execSync, spawn, spawnSync } = require("child_process");
18
+ const readline = require("readline");
19
+ const fs = require("fs");
20
+ const path = require("path");
21
+
22
+ // ─── 工具函数 ────────────────────────────────────────────────────────────────
23
+
24
+ /** 递归或单层列出目录中的文件 */
25
+ function listFiles(dir, recursive) {
26
+ let results = [];
27
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
28
+ for (const entry of entries) {
29
+ const full = path.join(dir, entry.name);
30
+ if (entry.isDirectory() && recursive) {
31
+ results = results.concat(listFiles(full, recursive));
32
+ } else if (entry.isFile()) {
33
+ results.push(full);
34
+ }
35
+ }
36
+ return results;
37
+ }
38
+
39
+ /** 视频扩展名白名单 */
40
+ const VIDEO_EXTS = new Set([
41
+ ".mp4", ".mkv", ".avi", ".wmv",
42
+ ".flv", ".webm", ".m4v", ".ts", ".mts", ".m2ts",
43
+ ]);
44
+
45
+ function isVideo(filePath) {
46
+ return VIDEO_EXTS.has(path.extname(filePath).toLowerCase());
47
+ }
48
+
49
+ /**
50
+ * 用 ffprobe 获取视频码率(kbps)
51
+ * 返回 null 表示无法读取
52
+ */
53
+ function getVideoBitrate(filePath) {
54
+ 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();
60
+
61
+ let kbps = parseInt(raw, 10);
62
+ if (!isNaN(kbps) && kbps > 0) return Math.round(kbps / 1000);
63
+
64
+ // 回退:读取容器级别码率
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();
70
+ kbps = parseInt(raw2, 10);
71
+ return (!isNaN(kbps) && kbps > 0) ? Math.round(kbps / 1000) : null;
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ /** 格式化文件大小 */
78
+ function fmtSize(bytes) {
79
+ if (bytes >= 1e9) return (bytes / 1e9).toFixed(2) + " GB";
80
+ if (bytes >= 1e6) return (bytes / 1e6).toFixed(2) + " MB";
81
+ return (bytes / 1e3).toFixed(2) + " KB";
82
+ }
83
+
84
+ // ─── 解析命令行参数 ──────────────────────────────────────────────────────────
85
+
86
+ function parseArgs(argv) {
87
+ const args = {};
88
+ for (let i = 0; i < argv.length; i++) {
89
+ const isLong = argv[i].startsWith("--");
90
+ const isShort = !isLong && argv[i].startsWith("-") && argv[i].length === 2;
91
+ 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;
94
+ }
95
+ }
96
+ return args;
97
+ }
98
+
99
+ // ─── Mode 1: find ────────────────────────────────────────────────────────────
100
+
101
+ function runFind(args) {
102
+ const dir = args.dir;
103
+ const threshold = parseInt(args.threshold || "4000", 10); // kbps
104
+ const outFile = args.output || "bitrate_list.json";
105
+ const recursive = !!args.recursive;
106
+
107
+ if (!dir) {
108
+ console.error("❌ 请用 --dir 指定扫描目录");
109
+ process.exit(1);
110
+ }
111
+ if (!fs.existsSync(dir)) {
112
+ console.error(`❌ 目录不存在:${dir}`);
113
+ process.exit(1);
114
+ }
115
+
116
+ console.log(`\n🔍 扫描目录:${path.resolve(dir)}`);
117
+ console.log(`📊 码率阈值:${threshold} kbps | 递归:${recursive}\n`);
118
+
119
+ const files = listFiles(dir, recursive).filter(isVideo);
120
+ const result = [];
121
+
122
+ for (const file of files) {
123
+ process.stdout.write(` 检测:${path.basename(file)} ... `);
124
+ const kbps = getVideoBitrate(file);
125
+ if (kbps === null) {
126
+ console.log("⚠️ 无法读取码率,跳过");
127
+ continue;
128
+ }
129
+ const size = fs.statSync(file).size;
130
+ if (kbps > threshold) {
131
+ console.log(`✅ ${kbps} kbps (${fmtSize(size)})`);
132
+ result.push({ path: path.resolve(file), bitrate_kbps: kbps, size_mb: parseFloat((size / 1e6).toFixed(2)) });
133
+ } else {
134
+ console.log(`—— ${kbps} kbps,未超阈值`);
135
+ }
136
+ }
137
+
138
+ console.log(`\n📋 共找到 ${result.length} 个超标视频`);
139
+
140
+ result.sort((a, b) => a.size_mb - b.size_mb);
141
+ fs.writeFileSync(outFile, JSON.stringify(result, null, 2), "utf8");
142
+ console.log(`💾 已保存到:${path.resolve(outFile)}`);
143
+ console.log(`\n下一步:\n vid-com compress --input ${outFile} --target 2000\n`);
144
+ }
145
+
146
+ // ─── Mode 2: compress ────────────────────────────────────────────────────────
147
+
148
+
149
+ /** 用 ffprobe 获取视频时长(秒),失败返回 0 */
150
+ function getVideoDuration(filePath) {
151
+ 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();
156
+ const sec = parseFloat(raw);
157
+ return isNaN(sec) ? 0 : sec;
158
+ } catch {
159
+ return 0;
160
+ }
161
+ }
162
+
163
+ /** 渲染进度条到当前行 */
164
+ function renderProgress(pct, speed, eta) {
165
+ const WIDTH = 30;
166
+ const filled = Math.round(WIDTH * pct / 100);
167
+ const bar = "█".repeat(filled) + "░".repeat(WIDTH - filled);
168
+ const etaStr = eta > 0 ? `ETA ${Math.ceil(eta)}s` : "ETA --";
169
+ process.stdout.write(`\r [${bar}] ${pct.toFixed(1).padStart(5)}% ${speed.padEnd(8)} ${etaStr} `);
170
+ }
171
+
172
+ /** 压缩单个文件,返回 Promise */
173
+ function compressOne(srcPath, destPath, targetKbps, qualityVal, audioKbps, durationSec) {
174
+ return new Promise((resolve, reject) => {
175
+ // targetKbps === null 表示使用 quality 模式
176
+ const videoArgs = targetKbps !== null
177
+ ? ["-b:v", `${targetKbps}k`]
178
+ : ["-q:v", String(qualityVal)];
179
+
180
+ const ffmpegArgs = [
181
+ "-y",
182
+ "-hwaccel", "videotoolbox", // 硬件解码
183
+ "-i", srcPath,
184
+ "-progress", "pipe:1", // 结构化进度 → stdout
185
+ "-nostats",
186
+ "-c:v", "hevc_videotoolbox", // 硬件编码 H.265
187
+ "-tag:v", "hvc1", // QuickTime/Apple 兼容 tag
188
+ "-pix_fmt", "nv12", // 与 videotoolbox 原生格式对齐,避免色彩转换
189
+ ...videoArgs,
190
+ "-map_metadata", "0", // 保留原始 meta(创建时间、GPS 等)
191
+ "-c:a", "aac",
192
+ "-b:a", `${audioKbps}k`,
193
+ "-movflags", "+faststart",
194
+ destPath,
195
+ ];
196
+
197
+ const proc = spawn("ffmpeg", ffmpegArgs, { stdio: ["ignore", "pipe", "ignore"] });
198
+
199
+ const startTime = Date.now();
200
+ let buf = "";
201
+
202
+ proc.stdout.on("data", (chunk) => {
203
+ buf += chunk.toString();
204
+ const lines = buf.split("\n");
205
+ buf = lines.pop(); // 保留不完整的最后一行
206
+
207
+ let outTimeMs = -1, speed = "";
208
+ for (const line of lines) {
209
+ if (line.startsWith("out_time_ms=")) outTimeMs = parseInt(line.slice(12), 10);
210
+ if (line.startsWith("speed=")) speed = line.slice(6).trim();
211
+ }
212
+
213
+ if (outTimeMs > 0 && durationSec > 0) {
214
+ const pct = Math.min((outTimeMs / 1e6 / durationSec) * 100, 100);
215
+ const elapsed = (Date.now() - startTime) / 1000;
216
+ const eta = pct > 0 ? (elapsed / pct) * (100 - pct) : 0;
217
+ renderProgress(pct, speed, eta);
218
+ }
219
+ });
220
+
221
+ proc.on("close", (code) => {
222
+ process.stdout.write("\n");
223
+ if (code === 0) resolve();
224
+ else reject(new Error(`ffmpeg 退出码 ${code}`));
225
+ });
226
+
227
+ proc.on("error", reject);
228
+ });
229
+ }
230
+
231
+ async function runCompress(args) {
232
+ const inputFile = args.input;
233
+ const fixedTargetKbps = args.target ? parseInt(args.target, 10) : null;
234
+ const qualityVal = args.quality ?? args.q ?? "65"; // -q / --quality,默认 65
235
+ const outDir = args.outdir || null;
236
+ const suffix = args.suffix || "_compressed";
237
+ const audioKbps = args.audio || "128";
238
+
239
+ if (!inputFile) {
240
+ console.error("❌ 请用 --input 指定 JSON 列表文件");
241
+ process.exit(1);
242
+ }
243
+ if (!fs.existsSync(inputFile)) {
244
+ console.error(`❌ 文件不存在:${inputFile}`);
245
+ process.exit(1);
246
+ }
247
+
248
+ const list = JSON.parse(fs.readFileSync(inputFile, "utf8"));
249
+ if (!Array.isArray(list) || list.length === 0) {
250
+ console.log("⚠️ 列表为空,无需处理");
251
+ return;
252
+ }
253
+
254
+ if (outDir) fs.mkdirSync(outDir, { recursive: true });
255
+
256
+ const modeDesc = fixedTargetKbps ? `固定码率 ${fixedTargetKbps} kbps` : `Quality 模式(-q:v ${qualityVal})`;
257
+ console.log(`\n🎬 开始压缩 | ${modeDesc} | 编码:hevc_videotoolbox\n`);
258
+
259
+ let done = 0, failed = 0;
260
+
261
+ for (let i = 0; i < list.length; i++) {
262
+ const item = list[i];
263
+ const srcPath = item.path || item;
264
+
265
+ if (!fs.existsSync(srcPath)) {
266
+ console.warn(`⚠️ 文件不存在,跳过:${srcPath}`);
267
+ failed++;
268
+ continue;
269
+ }
270
+
271
+ const ext = path.extname(srcPath);
272
+ const base = path.basename(srcPath, ext);
273
+ const destDir = outDir || path.dirname(srcPath);
274
+ const destPath = path.join(destDir, base + suffix + ext);
275
+
276
+ // fixedTargetKbps 有值时用固定码率,否则用 quality 模式(targetKbps = null)
277
+ const targetKbps = fixedTargetKbps ?? null;
278
+
279
+ console.log(`▶ [${i + 1}/${list.length}] ${path.basename(srcPath)}`);
280
+ console.log(` 原始码率:${item.bitrate_kbps ?? "?"} kbps → ${targetKbps !== null ? `${targetKbps} kbps` : `Quality -q:v ${qualityVal}`}`);
281
+ console.log(` 输出:${destPath}`);
282
+
283
+ const durationSec = getVideoDuration(srcPath);
284
+ const startTime = Date.now();
285
+
286
+ try {
287
+ await compressOne(srcPath, destPath, targetKbps, qualityVal, audioKbps, durationSec);
288
+
289
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
290
+ const newSize = fs.statSync(destPath).size;
291
+ const oldSize = item.size_mb != null ? item.size_mb * 1e6 : fs.statSync(srcPath).size;
292
+ const ratio = ((1 - newSize / oldSize) * 100).toFixed(1);
293
+
294
+ console.log(` ✅ 完成 耗时 ${elapsed}s ${fmtSize(oldSize)} → ${fmtSize(newSize)} 压缩率 ${ratio}%\n`);
295
+ done++;
296
+ } catch (err) {
297
+ console.error(` ❌ 失败:${err.message}\n`);
298
+ failed++;
299
+ }
300
+ }
301
+
302
+ console.log(`\n🏁 全部完成:成功 ${done} 个,失败 ${failed} 个\n`);
303
+ }
304
+
305
+ // ─── 共用删除工具 ─────────────────────────────────────────────────────────────
306
+
307
+ function getFileSizeBytes(filePath) {
308
+ try { return fs.statSync(filePath).size; } catch { return null; }
309
+ }
310
+
311
+ function askKey(question) {
312
+ return new Promise((resolve) => {
313
+ process.stdout.write(question);
314
+ if (process.stdin.isTTY) {
315
+ process.stdin.setRawMode(true);
316
+ process.stdin.resume();
317
+ process.stdin.once("data", (buf) => {
318
+ const key = buf.toString();
319
+ process.stdin.setRawMode(false);
320
+ process.stdin.pause();
321
+ process.stdout.write(key === "\r" || key === "\n" ? "\n" : key + "\n");
322
+ resolve(key);
323
+ });
324
+ } else {
325
+ const rl = readline.createInterface({ input: process.stdin, terminal: false });
326
+ rl.once("line", (line) => { rl.close(); resolve(line.trim()); });
327
+ }
328
+ });
329
+ }
330
+
331
+ // ─── Mode 3: delete ───────────────────────────────────────────────────────────
332
+
333
+ function getCompressedVideoPath(originalPath) {
334
+ const dir = path.dirname(originalPath);
335
+ const ext = path.extname(originalPath);
336
+ const base = path.basename(originalPath, ext);
337
+ const pattern = new RegExp(
338
+ "^" + base.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "_compressed\\.[mM][pP]4$"
339
+ );
340
+ try {
341
+ const match = fs.readdirSync(dir).find((f) => pattern.test(f));
342
+ return match ? path.join(dir, match) : path.join(dir, base + "_compressed.MP4");
343
+ } catch {
344
+ return path.join(dir, base + "_compressed.MP4");
345
+ }
346
+ }
347
+
348
+ function getVideoMeta(filePath) {
349
+ 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();
357
+ const obj = JSON.parse(raw);
358
+ return {
359
+ creation_time: obj.format?.tags?.creation_time || null,
360
+ duration: parseFloat(obj.format?.duration) || null,
361
+ };
362
+ } catch {
363
+ return null;
364
+ }
365
+ }
366
+
367
+ function checkVideoSourceMatch(origPath, compPath) {
368
+ const origMeta = getVideoMeta(origPath);
369
+ const compMeta = getVideoMeta(compPath);
370
+ const lines = [];
371
+
372
+ if (!origMeta || !compMeta) {
373
+ return { match: null, lines: [" 源信息 : 无法读取(ffprobe 失败),跳过验证"] };
374
+ }
375
+
376
+ let match = true;
377
+
378
+ if (origMeta.duration !== null && compMeta.duration !== null) {
379
+ const diff = Math.abs(origMeta.duration - compMeta.duration);
380
+ const ok = diff <= 1.0;
381
+ lines.push(` 时长 : ${origMeta.duration.toFixed(1)}s → ${compMeta.duration.toFixed(1)}s ${ok ? "✓" : "✗ 不匹配"}`);
382
+ if (!ok) match = false;
383
+ }
384
+
385
+ if (origMeta.creation_time && compMeta.creation_time) {
386
+ const ok = origMeta.creation_time === compMeta.creation_time;
387
+ lines.push(` 拍摄时间 : ${origMeta.creation_time} ${ok ? "✓" : "✗ 不匹配"}`);
388
+ if (!ok) match = false;
389
+ }
390
+
391
+ if (lines.length === 0) {
392
+ lines.push(" 源信息 : 无可用元数据,跳过验证");
393
+ return { match: null, lines };
394
+ }
395
+
396
+ return { match, lines };
397
+ }
398
+
399
+ async function runDelete(args, rest) {
400
+ const positional = rest.find((a) => !a.startsWith("-"));
401
+ const jsonFile = args.input || positional || path.join(__dirname, "my_list.json");
402
+
403
+ if (!fs.existsSync(jsonFile)) {
404
+ console.error(`File not found: ${jsonFile}`);
405
+ process.exit(1);
406
+ }
407
+
408
+ const list = JSON.parse(fs.readFileSync(jsonFile, "utf-8"));
409
+ if (!Array.isArray(list) || list.length === 0) {
410
+ console.log("No entries found in JSON.");
411
+ process.exit(0);
412
+ }
413
+
414
+ let deleted = 0, skipped = 0, noCompressed = 0, mismatch = 0;
415
+ console.log(`Processing ${list.length} file(s) from: ${jsonFile}\n`);
416
+
417
+ for (let i = 0; i < list.length; i++) {
418
+ const item = list[i];
419
+ const origPath = item.path;
420
+ const compPath = getCompressedVideoPath(origPath);
421
+
422
+ console.log(`[${i + 1}/${list.length}]`);
423
+ console.log(` Original : ${origPath}`);
424
+
425
+ const origSize = getFileSizeBytes(origPath);
426
+ if (origSize === null) {
427
+ console.log(` Original : NOT FOUND, skipping\n`);
428
+ skipped++;
429
+ continue;
430
+ }
431
+ console.log(` Orig size : ${fmtSize(origSize)}`);
432
+
433
+ const compSize = getFileSizeBytes(compPath);
434
+ if (compSize === null) {
435
+ console.log(` Compressed: NOT FOUND (${path.basename(compPath)}), skipping\n`);
436
+ noCompressed++;
437
+ continue;
438
+ }
439
+
440
+ console.log(` Compressed: ${compPath}`);
441
+ console.log(` Comp size : ${fmtSize(compSize)}`);
442
+ console.log(` Saved : ${fmtSize(origSize - compSize)} (${(((origSize - compSize) / origSize) * 100).toFixed(1)}%)`);
443
+
444
+ const { match, lines } = checkVideoSourceMatch(origPath, compPath);
445
+ lines.forEach((l) => console.log(l));
446
+
447
+ if (match === false) {
448
+ console.log(" -> 源信息不匹配,自动跳过\n");
449
+ mismatch++;
450
+ continue;
451
+ }
452
+
453
+ const key = await askKey(" Delete original? [y = yes / other = skip]: ");
454
+ if (key.toLowerCase() === "y") {
455
+ try {
456
+ fs.unlinkSync(origPath);
457
+ console.log(" -> DELETED\n");
458
+ deleted++;
459
+ } catch (err) {
460
+ console.error(` -> FAILED: ${err.message}\n`);
461
+ }
462
+ } else {
463
+ console.log(" -> Skipped\n");
464
+ skipped++;
465
+ }
466
+ }
467
+
468
+ console.log("─".repeat(50));
469
+ console.log(`Deleted: ${deleted} | Skipped: ${skipped} | No compressed: ${noCompressed} | Mismatch: ${mismatch}`);
470
+ }
471
+
472
+ // ─── Mode 4: arw-convert ─────────────────────────────────────────────────────
473
+
474
+ function runArwConvert(positionals) {
475
+ const srcDir = positionals[0];
476
+ const outDir = positionals[1];
477
+ if (!srcDir) {
478
+ console.error("❌ 请指定源目录:vid-com arw-convert <源目录> [输出目录]");
479
+ process.exit(1);
480
+ }
481
+ const scriptPath = path.join(__dirname, "scripts", "arw_to_jpg.sh");
482
+ const result = spawnSync("bash", [scriptPath, srcDir, ...(outDir ? [outDir] : [])], {
483
+ stdio: "inherit",
484
+ });
485
+ if (result.status !== 0) process.exit(result.status || 1);
486
+ }
487
+
488
+ // ─── Mode 5: arw-delete ──────────────────────────────────────────────────────
489
+
490
+ function getImageDimensions(filePath) {
491
+ try {
492
+ const out = execSync(
493
+ `sips -g pixelWidth -g pixelHeight "${filePath}"`,
494
+ { stdio: ["pipe", "pipe", "pipe"] }
495
+ ).toString();
496
+ const w = out.match(/pixelWidth:\s*(\d+)/)?.[1];
497
+ const h = out.match(/pixelHeight:\s*(\d+)/)?.[1];
498
+ if (w && h) return { width: parseInt(w, 10), height: parseInt(h, 10) };
499
+ return null;
500
+ } catch {
501
+ return null;
502
+ }
503
+ }
504
+
505
+ function findJpgForArw(arwPath) {
506
+ const dir = path.dirname(arwPath);
507
+ const base = path.basename(arwPath, path.extname(arwPath)).toLowerCase();
508
+ try {
509
+ const match = fs.readdirSync(dir).find((f) => {
510
+ const fExt = path.extname(f).toLowerCase();
511
+ const fBase = path.basename(f, path.extname(f)).toLowerCase();
512
+ return fBase === base && (fExt === ".jpg" || fExt === ".jpeg");
513
+ });
514
+ return match ? path.join(dir, match) : null;
515
+ } catch {
516
+ return null;
517
+ }
518
+ }
519
+
520
+ function listArwFiles(dir) {
521
+ try {
522
+ return fs.readdirSync(dir, { withFileTypes: true })
523
+ .filter((e) => e.isFile() && path.extname(e.name).toLowerCase() === ".arw")
524
+ .map((e) => path.join(dir, e.name));
525
+ } catch {
526
+ return [];
527
+ }
528
+ }
529
+
530
+ async function runArwDelete(args) {
531
+ const dir = args.dir;
532
+ if (!dir) {
533
+ console.error("❌ 请用 --dir 指定目录");
534
+ process.exit(1);
535
+ }
536
+ if (!fs.existsSync(dir)) {
537
+ console.error(`❌ 目录不存在:${dir}`);
538
+ process.exit(1);
539
+ }
540
+
541
+ const arwFiles = listArwFiles(dir);
542
+ if (arwFiles.length === 0) {
543
+ console.log(`未找到 .arw 文件:${dir}`);
544
+ process.exit(0);
545
+ }
546
+
547
+ let deleted = 0, skipped = 0, noJpg = 0, mismatch = 0;
548
+ console.log(`扫描到 ${arwFiles.length} 个 .arw 文件,目录:${dir}\n`);
549
+
550
+ for (let i = 0; i < arwFiles.length; i++) {
551
+ const arwPath = arwFiles[i];
552
+ const jpgPath = findJpgForArw(arwPath);
553
+
554
+ console.log(`[${i + 1}/${arwFiles.length}]`);
555
+ console.log(` ARW : ${path.basename(arwPath)}`);
556
+
557
+ const arwSize = getFileSizeBytes(arwPath);
558
+ if (arwSize === null) {
559
+ console.log(` ARW : NOT FOUND, skipping\n`);
560
+ skipped++;
561
+ continue;
562
+ }
563
+ console.log(` 大小 : ${fmtSize(arwSize)}`);
564
+
565
+ if (!jpgPath) {
566
+ console.log(` JPG : NOT FOUND,跳过\n`);
567
+ noJpg++;
568
+ continue;
569
+ }
570
+
571
+ const jpgSize = getFileSizeBytes(jpgPath);
572
+ console.log(` JPG : ${path.basename(jpgPath)} (${fmtSize(jpgSize ?? 0)})`);
573
+
574
+ const arwDim = getImageDimensions(arwPath);
575
+ const jpgDim = getImageDimensions(jpgPath);
576
+
577
+ if (!arwDim || !jpgDim) {
578
+ console.log(` 尺寸 : 无法读取(sips 失败),跳过\n`);
579
+ mismatch++;
580
+ continue;
581
+ }
582
+
583
+ const dimMatch = arwDim.width === jpgDim.width && arwDim.height === jpgDim.height;
584
+ console.log(
585
+ ` 尺寸 : ${arwDim.width}×${arwDim.height} vs ${jpgDim.width}×${jpgDim.height} ${dimMatch ? "✓" : "✗ 不匹配"}`
586
+ );
587
+
588
+ if (!dimMatch) {
589
+ console.log(` -> 尺寸不匹配,自动跳过\n`);
590
+ mismatch++;
591
+ continue;
592
+ }
593
+
594
+ const key = await askKey(" Delete ARW? [y = yes / other = skip]: ");
595
+ if (key.toLowerCase() === "y") {
596
+ try {
597
+ fs.unlinkSync(arwPath);
598
+ console.log(" -> DELETED\n");
599
+ deleted++;
600
+ } catch (err) {
601
+ console.error(` -> FAILED: ${err.message}\n`);
602
+ }
603
+ } else {
604
+ console.log(" -> Skipped\n");
605
+ skipped++;
606
+ }
607
+ }
608
+
609
+ console.log("─".repeat(50));
610
+ console.log(`Deleted: ${deleted} | Skipped: ${skipped} | No JPG: ${noJpg} | Mismatch: ${mismatch}`);
611
+ }
612
+
613
+ // ─── 帮助信息 ────────────────────────────────────────────────────────────────
614
+
615
+ function printHelp() {
616
+ console.log(`
617
+ media-compress — Video & ARW media toolkit / 视频扫描压缩 & ARW 转换工具
618
+ ═══════════════════════════════════════════════════════════════════════════════
619
+
620
+ Usage / 用法:vid-com <command> [options]
621
+
622
+ Commands / 命令:
623
+ find Scan for high-bitrate videos, output JSON list
624
+ 扫描目录,找出高码率视频,输出 JSON 列表
625
+ compress Batch compress using FFmpeg hardware acceleration
626
+ 读取 JSON 列表,FFmpeg 硬件加速批量压缩
627
+ delete Verify compressed files then delete originals
628
+ 校验压缩文件后删除原始视频
629
+ arw-convert Convert ARW RAW files to JPEG (parallel, macOS sips)
630
+ 批量转换 ARW 为 JPEG(多线程,macOS sips)
631
+ arw-delete Verify JPEG exists then delete ARW originals
632
+ 校验 JPEG 后删除 ARW 原始文件
633
+
634
+ ─────────────────────────────────────────────────────────────────────────────
635
+ find options:
636
+
637
+ --dir <path> Required. Directory to scan / 必填,扫描目录
638
+ --threshold <kbps> Bitrate threshold, default 4000 / 码率阈值,默认 4000
639
+ --output <file> Output JSON file, default bitrate_list.json
640
+ --recursive Recurse into subdirectories / 递归扫描子目录
641
+
642
+ ─────────────────────────────────────────────────────────────────────────────
643
+ compress options:
644
+
645
+ --input <json> Required. JSON list from find
646
+ --outdir <dir> Output directory, default: same as source
647
+ --suffix <suffix> Filename suffix, default _compressed
648
+ -q/--quality <0-100> Quality value, higher is better, default 65
649
+ --target <kbps> Fixed bitrate mode, overrides -q
650
+ --audio <kbps> Audio bitrate, default 128
651
+
652
+ Encoder:hevc_videotoolbox (Apple hardware H.265, macOS only)
653
+
654
+ ─────────────────────────────────────────────────────────────────────────────
655
+ delete options:
656
+
657
+ --input <json> JSON list from find (default: my_list.json)
658
+
659
+ ─────────────────────────────────────────────────────────────────────────────
660
+ arw-convert usage:
661
+
662
+ vid-com arw-convert <源目录> [输出目录]
663
+ 输出目录默认为 ./converted_jpgs,质量 90,多线程并发
664
+
665
+ ─────────────────────────────────────────────────────────────────────────────
666
+ arw-delete options:
667
+
668
+ --dir <path> Required. Directory containing ARW files
669
+
670
+ ─────────────────────────────────────────────────────────────────────────────
671
+ Video pipeline / 视频工作流:
672
+
673
+ vid-com find --dir ~/Videos --recursive --output /tmp/list.json
674
+ vid-com compress --input /tmp/list.json --outdir ~/Videos/out
675
+ vid-com delete --input /tmp/list.json
676
+
677
+ ARW pipeline / ARW 工作流:
678
+
679
+ vid-com arw-convert /Volumes/Untitled/DCIM /Volumes/QWER/out
680
+ vid-com arw-delete --dir /Volumes/Untitled/DCIM
681
+
682
+ ═══════════════════════════════════════════════════════════════════════════════
683
+ `);
684
+ }
685
+
686
+ // ─── 入口 ────────────────────────────────────────────────────────────────────
687
+
688
+ const [,, mode, ...rest] = process.argv;
689
+ const args = parseArgs(rest);
690
+
691
+ if (!mode || mode === "--help" || mode === "-h") {
692
+ printHelp();
693
+ } else if (mode === "find") {
694
+ runFind(args);
695
+ } else if (mode === "compress") {
696
+ runCompress(args).catch((err) => { console.error(err); process.exit(1); });
697
+ } else if (mode === "delete") {
698
+ runDelete(args, rest).catch((err) => { console.error(err); process.exit(1); });
699
+ } else if (mode === "arw-convert") {
700
+ const positionals = rest.filter((a) => !a.startsWith("-"));
701
+ runArwConvert(positionals);
702
+ } else if (mode === "arw-delete") {
703
+ runArwDelete(args).catch((err) => { console.error(err); process.exit(1); });
704
+ } else {
705
+ console.error(`❌ 未知命令:${mode}(可用:find / compress / delete / arw-convert / arw-delete)`);
706
+ printHelp();
707
+ process.exit(1);
708
+ }