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 +228 -0
- package/package.json +26 -0
- package/scripts/arw_to_jpg.sh +56 -0
- package/scripts/batch_delete.js +347 -0
- package/scripts/check-deps.js +17 -0
- package/video_bitrate_tool.js +708 -0
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
|
+
}
|