video-pipeline 1.0.4 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,20 +1,17 @@
1
1
  # Changelog
2
2
 
3
- ## [1.0.4] - 2026-06-11
3
+ ## [1.1.0] - 2026-06-11
4
4
 
5
5
  ### Features
6
6
 
7
- - rename npm package to video-pipeline (`644cc8a`)
8
- - add --url option to Python version (parity with Node.js) (`e1df301`)
7
+ - add --input option for local file processing (Node.js + Python) (`4805e3b`)
9
8
 
10
9
  ### Bug Fixes
11
10
 
12
- - recreate v1.0.3 tag and regenerate CHANGELOG with correct scopes (`9c2db90`)
13
- - normalize CHANGELOG format to Keep a Changelog standard (`2b887f4`)
14
- - correct changelog per-version ranges + fix getLastTag for Windows (`755222a`)
11
+ - whisper transcribe FormData + multi --step + auto-find wav (`f8c9fa2`)
15
12
 
16
13
 
17
- ## [Unreleased]
14
+ ## [1.0.4] - 2026-06-11
18
15
 
19
16
  ### Features
20
17
 
@@ -23,6 +20,7 @@
23
20
 
24
21
  ### Bug Fixes
25
22
 
23
+ - recreate v1.0.3 tag and regenerate CHANGELOG with correct scopes (`9c2db90`)
26
24
  - normalize CHANGELOG format to Keep a Changelog standard (`2b887f4`)
27
25
  - correct changelog per-version ranges + fix getLastTag for Windows (`755222a`)
28
26
 
package/README.md CHANGED
@@ -1,6 +1,38 @@
1
- # 视频下载 / 转码 / 文本识别 / AI 分析 流程
1
+ # 视频处理流水线 (Video Pipeline)
2
2
 
3
- 基于 `process_videos.py`,一键完成:yt-dlp 下载 → ffmpeg 转码 → whisper 识别 → AI 关键词归纳 → 写回 Excel。
3
+ 基于 `process_videos.js` (Node.js) 或 `process_videos.py` (Python),一键完成:yt-dlp 下载 → ffmpeg 转码 → whisper 识别 → AI 关键词归纳 → 写回 Excel。
4
+
5
+ **两种使用方式:**
6
+ - **Excel 批量处理**:从 Excel 文件读取视频 ID,自动完成全流程
7
+ - **直链下载**:通过 `--url` 直接指定视频链接,自动识别平台并下载
8
+ - **本地文件**:通过 `--input` 指定本地视频文件,跳过下载直接转码分析
9
+
10
+ ---
11
+
12
+ ## 安装方式
13
+
14
+ ### Node.js 版本(推荐)
15
+
16
+ ```bash
17
+ # 全局安装
18
+ npm install -g video-pipeline
19
+
20
+ # 使用后可直接调用
21
+ video-pipeline --help
22
+ ```
23
+
24
+ ### Python 版本
25
+
26
+ ```bash
27
+ # 克隆或下载脚本
28
+ git clone https://gitee.com/siriussupreme/yt-dlp_ffmpeg_whisper_memo-ai.git
29
+ cd yt-dlp_ffmpeg_whisper_memo-ai
30
+
31
+ # 安装 Python 依赖
32
+ pip install pandas openpyxl requests python-dotenv questionary
33
+ ```
34
+
35
+ ---
4
36
 
5
37
  ## 环境依赖
6
38
 
@@ -110,21 +142,33 @@ WHISPER_LANGUAGE=zh # 空=多语言自动检测(默认),需要指
110
142
  ## 目录结构
111
143
 
112
144
  ```
113
- ├── process_videos.py # 主流程脚本
114
- ├── .env.example # 环境变量模板(可提交 Git)
115
- ├── .env # 实际环境变量(已 gitignore,按需修改)
116
- ├── export_2026-06-10_split.xlsx # 数据源(YouTube视频 / 普诺赛中文站 两个 sheet
117
- ├── cookies/
118
- ├── bilibili.txt # B站 cookie(Netscape 格式)
119
- │ └── youtube.txt # YouTube cookie 备用(Firefox 直读方案不需要)
120
- ├── downloads/ # yt-dlp 下载输出(mp4)
145
+ ├── process_videos.js # Node.js 主流程脚本(推荐)
146
+ ├── process_videos.py # Python 主流程脚本(备选)
147
+ ├── package.json # Node.js 项目配置(npm 包)
148
+ ├── .env.example # 环境变量模板(可提交 Git
149
+ ├── .env # 实际环境变量(已 gitignore,按需修改)
150
+ ├── data/ # 数据源目录
151
+ │ └── export_2026-06-10_split.xlsx # Excel 数据源
152
+ ├── cookies/ # 站点 cookie 文件
153
+ │ ├── bilibili.txt # B站 cookie(Netscape 格式)
154
+ │ └── youtube.txt # YouTube cookie 备用(Firefox 直读方案不需要)
155
+ ├── downloads/ # yt-dlp 下载输出(mp4)
121
156
  │ ├── YouTube视频/
122
157
  │ └── 普诺赛中文站/
123
- ├── transcoded/ # ffmpeg 转码输出(wav 16kHz mono)
158
+ ├── transcoded/ # ffmpeg 转码输出(wav 16kHz mono)
124
159
  │ ├── YouTube视频/
125
160
  │ └── 普诺赛中文站/
126
- └── reports/ # 执行报告(JSON)
127
- └── report_YYYYMMDD_HHMMSS.json
161
+ ├── reports/ # 执行报告(JSON)
162
+ └── report_YYYYMMDD_HHMMSS.json
163
+ ├── scripts/ # 辅助脚本
164
+ │ ├── release.js # 版本发布脚本
165
+ │ └── regenerate-changelog.js # CHANGELOG 重建脚本
166
+ ├── .github/ # GitHub Actions 工作流
167
+ ├── .husky/ # Git hooks(commit 消息检查)
168
+ ├── node_modules/ # Node.js 依赖(已 gitignore)
169
+ ├── CHANGELOG.md # 版本变更记录
170
+ ├── README.md # 使用文档
171
+ └── LICENSE # MIT 许可证
128
172
  ```
129
173
 
130
174
  ---
@@ -167,35 +211,39 @@ yt-dlp 可直接从 Firefox 浏览器读取 cookie,无需手动导出:
167
211
 
168
212
  ```bash
169
213
  # 下载 + 转码 + 识别 + AI分析,指定 sheet + extra.id
214
+ node process_videos.js --sheet "YouTube视频" --id 2143
215
+ # 或 Python 版本
170
216
  python process_videos.py --sheet "YouTube视频" --id 2143
171
217
 
172
218
  # 只跑下载
219
+ node process_videos.js --sheet "普诺赛中文站" --id 16 --step download
220
+ # 或 Python 版本
173
221
  python process_videos.py --sheet "普诺赛中文站" --id 16 --step download
174
222
 
175
223
  # 只跑转码(需要已有下载文件)
176
- python process_videos.py --sheet "普诺赛中文站" --id 16 --step transcode
224
+ node process_videos.js --sheet "普诺赛中文站" --id 16 --step transcode
177
225
 
178
226
  # 只跑识别(需要已有转码文件)
179
- python process_videos.py --sheet "普诺赛中文站" --id 16 --step transcribe
227
+ node process_videos.js --sheet "普诺赛中文站" --id 16 --step transcribe
180
228
 
181
229
  # 只跑 AI 分析(需要已有识别文本)
182
- python process_videos.py --sheet "普诺赛中文站" --id 16 --step analyze
230
+ node process_videos.js --sheet "普诺赛中文站" --id 16 --step analyze
183
231
 
184
232
  # 强制重新下载(忽略已有文件)
185
- python process_videos.py --sheet "YouTube视频" --id 2143 --force
233
+ node process_videos.js --sheet "YouTube视频" --id 2143 --force
186
234
  ```
187
235
 
188
236
  ### 批量全量
189
237
 
190
238
  ```bash
191
239
  # 全量执行(2 个并发,失败重试 3 次)
192
- python process_videos.py --concurrency 2 --retry 3
240
+ node process_videos.js --concurrency 2 --retry 3
193
241
 
194
242
  # 只跑某一 sheet
195
- python process_videos.py --sheet "YouTube视频" --concurrency 2 --retry 3
243
+ node process_videos.js --sheet "YouTube视频" --concurrency 2 --retry 3
196
244
 
197
245
  # 先干跑预览
198
- python process_videos.py --dry-run
246
+ node process_videos.js --dry-run
199
247
  ```
200
248
 
201
249
  ### 重跑失败
@@ -203,10 +251,10 @@ python process_videos.py --dry-run
203
251
  ```bash
204
252
  # 第一次跑完后生成 reports/report_xxx.json
205
253
  # 查看失败项:
206
- python process_videos.py --retry-failed reports/report_20260610_143000.json --dry-run
254
+ node process_videos.js --retry-failed reports/report_20260610_143000.json --dry-run
207
255
 
208
256
  # 重跑:
209
- python process_videos.py --retry-failed reports/report_20260610_143000.json --concurrency 2 --retry 3
257
+ node process_videos.js --retry-failed reports/report_20260610_143000.json --concurrency 2 --retry 3
210
258
  ```
211
259
 
212
260
  ### 超时控制(防止任务卡死)
@@ -215,7 +263,7 @@ python process_videos.py --retry-failed reports/report_20260610_143000.json --co
215
263
 
216
264
  ```bash
217
265
  # 自定义超时(单位秒)
218
- python process_videos.py \
266
+ node process_videos.js \
219
267
  --download-timeout 900 \ # 下载 15 分钟
220
268
  --transcode-timeout 600 \ # 转码 10 分钟
221
269
  --transcribe-timeout 1200 \ # 识别 20 分钟
@@ -228,6 +276,51 @@ python process_videos.py \
228
276
  - 无论超时多少次,**不会阻塞其他并发任务**,失败项会记录到报告
229
277
  - 超时失败的任务可用 `--retry-failed` 单独重跑
230
278
 
279
+ ### 直接指定 URL 下载
280
+
281
+ ```bash
282
+ # 直接指定视频链接,自动识别平台(支持标准链接、短链接、内嵌链接)
283
+ node process_videos.js --url "https://www.youtube.com/watch?v=zzJmKPX8a3c"
284
+ python process_videos.py --url "https://www.bilibili.com/video/BV1xx411c7mD"
285
+
286
+ # 指定输出文件名(不含扩展名)
287
+ node process_videos.js --url "https://youtu.be/zzJmKPX8a3c" --name "产品介绍"
288
+
289
+ # 只执行部分步骤
290
+ node process_videos.js --url "https://www.youtube.com/watch?v=zzJmKPX8a3c" --step transcode
291
+ ```
292
+
293
+ **支持的 URL 格式:**
294
+ - YouTube: 标准页、短链接、Shorts、内嵌页、直播
295
+ - B站: 标准页(BV/av号)、短链接、内嵌页、移动端
296
+ - 腾讯视频: 标准页、内嵌页、移动端
297
+ - 优酷: 标准页
298
+
299
+ **文件命名规则:**
300
+ - 默认:`{平台}_{视频ID}`(如 `youtube_zzJmKPX8a3c`)
301
+ - 自定义:通过 `--name` 指定(如 `--name "产品介绍"`)
302
+ - 冲突处理:自动提示选择(覆盖 / 跳过 / 自定义名称)
303
+
304
+ ### 处理本地文件
305
+
306
+ ```bash
307
+ # 指定本地视频文件,跳过下载,直接转码→识别→分析
308
+ node process_videos.js --input "downloads/产品介绍.mp4"
309
+ python process_videos.py --input "downloads/产品介绍.mp4"
310
+
311
+ # 指定输出文件名
312
+ node process_videos.js --input "downloads/产品介绍.mp4" --name "产品介绍_分析"
313
+
314
+ # 只执行部分步骤
315
+ node process_videos.js --input "downloads/产品介绍.mp4" --step analyze
316
+ ```
317
+
318
+ **文件校验:**
319
+ - 检查文件是否存在
320
+ - 检查文件格式是否支持(视频/音频)
321
+ - 检查是否可以正常读取
322
+ - 校验失败会提示错误并退出
323
+
231
324
  ### 工具预检(执行前自动检测)
232
325
 
233
326
  每次执行任务前,脚本会自动检测本次涉及步骤所需的工具/服务是否可用:
@@ -249,19 +342,25 @@ python process_videos.py \
249
342
 
250
343
  | 参数 | 类型 | 默认值 | 说明 |
251
344
  |---|---|---|---|
252
- | `--sheet` | str | 全部 | 指定 sheet:`YouTube视频` `普诺赛中文站` |
253
- | `--id` | str | — | 指定 extra.id 或 title(单条测试) |
254
- | `--step` | str | 全跑 | 只执行某步:`download` / `transcode` / `transcribe` |
345
+ | `--sheet <name>` | str | 全部 | 指定 sheet 名称 |
346
+ | `--id <id>` | str | — | 指定 extra.id 或 title(单条测试) |
347
+ | `--step <step>` | str | 全跑 | 只执行某步:`download` / `transcode` / `transcribe` / `analyze` |
255
348
  | `--force` | flag | off | 强制重做下载+转码,忽略已有文件 |
256
- | `--concurrency` | int | 1 | 并发数,建议 2~3 |
257
- | `--retry` | int | 0 | 每步失败最大重试次数 |
258
- | `--retry-delay` | float | 5 | 重试间隔基数(秒),指数退避 5→10→20 |
259
- | `--download-timeout` | int | 600 | 单个下载任务最长执行时间(秒) |
260
- | `--transcode-timeout` | int | 600 | 单个转码任务最长执行时间(秒) |
261
- | `--transcribe-timeout` | int | 600 | 单个识别任务最长执行时间(秒) |
262
- | `--analyze-timeout` | int | 300 | 单个 AI 分析任务最长执行时间(秒) |
349
+ | `--concurrency <n>` | int | 1 | 并发数,建议 2~3 |
350
+ | `--retry <n>` | int | 0 | 每步失败最大重试次数 |
351
+ | `--retry-delay <n>` | float | 5 | 重试间隔基数(秒),指数退避 5→10→20 |
352
+ | `--download-timeout <n>` | int | 600 | 单个下载任务最长执行时间(秒) |
353
+ | `--transcode-timeout <n>` | int | 600 | 单个转码任务最长执行时间(秒) |
354
+ | `--transcribe-timeout <n>` | int | 600 | 单个识别任务最长执行时间(秒) |
355
+ | `--analyze-timeout <n>` | int | 300 | 单个 AI 分析任务最长执行时间(秒) |
263
356
  | `--dry-run` | flag | off | 干跑模式,只列任务不执行 |
264
- | `--retry-failed` | path | — | 从报告 JSON 重跑失败项 |
357
+ | `--retry-failed <path>` | path | — | 从报告 JSON 重跑失败项 |
358
+ | `--init` | flag | off | 复制 .env.example 到当前目录并重命名为 .env |
359
+ | `--file <path>` | path | — | 指定 Excel 文件路径(优先级高于 EXCEL_FILE 环境变量) |
360
+ | `--input <path>` | path | — | 指定本地视频文件路径(跳过下载,直接转码→识别→分析) |
361
+ | `--url <url>` | str | — | 直接指定视频下载链接(跳过 Excel),支持标准链接和内嵌链接 |
362
+ | `--name <name>` | str | — | 指定输出文件名,不含扩展名(与 --url / --input 配合使用) |
363
+ | `--env-file <path>` | path | .env | 指定要加载的 .env 文件路径 |
265
364
 
266
365
  ---
267
366
 
@@ -340,11 +439,13 @@ AI_TIMEOUT=300
340
439
 
341
440
  ```bash
342
441
  # 已有识别文本,只跑 AI 分析
442
+ node process_videos.js --sheet "普诺赛中文站" --id 427 --step analyze
443
+ # 或 Python 版本
343
444
  python process_videos.py --sheet "普诺赛中文站" --id 427 --step analyze
344
445
 
345
446
  # 单独跑 analyze 超过 16 条不会写入 Excel
346
447
  # 要想写入 Excel 跑完整流程 --step analyze
347
- python process_videos.py --sheet "YouTube视频" --step analyze --concurrency 2
448
+ node process_videos.js --sheet "YouTube视频" --step analyze --concurrency 2
348
449
  ```
349
450
 
350
451
  ### 禁用 AI 分析
@@ -442,16 +543,18 @@ python process_videos.py --sheet "YouTube视频" --step analyze --concurrency 2
442
543
 
443
544
  ```bash
444
545
  # 1. 干跑预览
546
+ node process_videos.js --dry-run
547
+ # 或 Python 版本
445
548
  python process_videos.py --dry-run
446
549
 
447
550
  # 2. 单条验证
448
- python process_videos.py --sheet "YouTube视频" --id 2143 --retry 2
551
+ node process_videos.js --sheet "YouTube视频" --id 2143 --retry 2
449
552
 
450
553
  # 3. 全量执行
451
- python process_videos.py --concurrency 3 --retry 3
554
+ node process_videos.js --concurrency 3 --retry 3
452
555
 
453
556
  # 4. 查看报告,重跑失败项
454
- python process_videos.py --retry-failed reports/report_xxx.json --concurrency 2 --retry 3
557
+ node process_videos.js --retry-failed reports/report_xxx.json --concurrency 2 --retry 3
455
558
  ```
456
559
 
457
560
  ---
@@ -568,22 +671,58 @@ python process_videos.py --retry-failed reports/report_xxx.json --concurrency 2
568
671
 
569
672
  ## 换电脑使用
570
673
 
571
- 1. 安装上述所有必装工具,确保 `yt-dlp`、`ffmpeg`、`ffprobe`、`node` 均在 PATH
572
- 2. `pip install pandas openpyxl requests python-dotenv`
573
- 3. `cp .env.example .env`,根据实际情况修改 `.env` 中的路径、代理端口和字段映射
574
- 4. 用 Firefox 登录 YouTube,设置 `YOUTUBE_COOKIES_FROM_BROWSER=firefox`
575
- 5. B站 cookie 仍需手动导出 `cookies/bilibili.txt`
576
- 6. 启动代理(Clash Verge 等),确认端口匹配 `YOUTUBE_PROXY`
577
- 7. `python process_videos.py --dry-run` 验证
674
+ ### Node.js 版本
675
+
676
+ 1. 安装 Node.js (18+):[nodejs.org](https://nodejs.org/)
677
+ 2. 安装视频处理工具:
678
+ ```bash
679
+ npm install -g video-pipeline
680
+ ```
681
+ 3. 克隆或下载项目文件(`.env.example`、`.env`、`cookies/` 等)
682
+ 4. 安装必装工具:`yt-dlp`、`ffmpeg`、`ffprobe`,确保均在 PATH
683
+ 5. 用 Firefox 登录 YouTube,设置 `YOUTUBE_COOKIES_FROM_BROWSER=firefox`
684
+ 6. B站 cookie 仍需手动导出 `cookies/bilibili.txt`
685
+ 7. 启动代理(Clash Verge 等),确认端口匹配 `YOUTUBE_PROXY`
686
+ 8. `video-pipeline --dry-run` 验证
687
+
688
+ ### Python 版本
689
+
690
+ 1. 安装 Python 3.9+:[python.org](https://www.python.org/)
691
+ 2. 安装必装工具:`yt-dlp`、`ffmpeg`、`ffprobe`,确保均在 PATH
692
+ 3. 安装 Python 依赖:`pip install pandas openpyxl requests python-dotenv questionary`
693
+ 4. `cp .env.example .env`,根据实际情况修改 `.env` 中的路径、代理端口和字段映射
694
+ 5. 用 Firefox 登录 YouTube,设置 `YOUTUBE_COOKIES_FROM_BROWSER=firefox`
695
+ 6. B站 cookie 仍需手动导出 `cookies/bilibili.txt`
696
+ 7. 启动代理(Clash Verge 等),确认端口匹配 `YOUTUBE_PROXY`
697
+ 8. `python process_videos.py --dry-run` 验证
698
+
699
+ ---
578
700
 
579
701
  ## 适配其他 Excel
580
702
 
581
703
  如果需要用这套脚本处理**其他项目的 Excel**(列名不同、平台不同):
582
704
 
705
+ **方法一:修改 .env 文件**
706
+
583
707
  1. 复制 `.env.example` 为新 `.env`(或修改现有 `.env`)
584
708
  2. 修改 `EXCEL_FILE` 指向新 Excel
585
709
  3. 修改列映射(`COL_ID`、`COL_TITLE`、`COL_CONTENT` 及各平台列名)
586
710
  4. 修改 `VIDEO_SHEETS` 为新的 sheet 名称
587
711
  5. 如需新平台,在 `PLATFORM_PRIORITY` 中添加 key,并配置对应的 `{KEY}_URL_TPL`
588
- 6. `python process_videos.py --dry-run` 验证配置
712
+ 6. `node process_videos.js --dry-run` 验证配置
589
713
  7. 跑全量
714
+
715
+ **方法二:使用 --file 选项(推荐)**
716
+
717
+ ```bash
718
+ # 直接指定 Excel 文件,无需修改 .env
719
+ node process_videos.js --file "data/其他项目.xlsx" --dry-run
720
+
721
+ # 配合 --env-file 使用自定义环境变量
722
+ node process_videos.js --file "data/其他项目.xlsx" --env-file ".env.其他项目" --dry-run
723
+ ```
724
+
725
+ **优点:**
726
+ - 无需修改 `.env` 文件
727
+ - 可以为不同项目创建不同的 `.env` 配置文件
728
+ - 命令行优先级高于环境变量
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "video-pipeline",
3
- "version": "1.0.4",
3
+ "version": "1.1.0",
4
4
  "description": "视频下载、转码、文本识别、AI 关键词分析一体化流程 CLI 工具",
5
5
  "keywords": [
6
6
  "video",
package/process_videos.js CHANGED
@@ -80,6 +80,21 @@ const PLATFORM_COL_MAP = {
80
80
  youkuId: COL_YOUKUID,
81
81
  };
82
82
 
83
+ // ============================== 工具函数 ==============================
84
+ function c(color, text) {
85
+ const colors = {
86
+ dim: '\x1b[2m',
87
+ yellow: '\x1b[33m',
88
+ cyan: '\x1b[36m',
89
+ green: '\x1b[32m',
90
+ red: '\x1b[31m',
91
+ blue: '\x1b[34m',
92
+ magenta: '\x1b[35m',
93
+ reset: '\x1b[0m',
94
+ };
95
+ return (colors[color] || '') + text + colors.reset;
96
+ }
97
+
83
98
  const PLATFORM_PRIORITY = (process.env.PLATFORM_PRIORITY || 'bilibiliBvid,youtubeId,tencentVid,youkuId')
84
99
  .split(',').map(s => s.trim()).filter(Boolean);
85
100
 
@@ -1033,11 +1048,9 @@ async function transcribeService(audioFile, stem, maxRetries, retryDelay, timeou
1033
1048
  }
1034
1049
 
1035
1050
  // Run inference
1036
- const fileStream = fs.createReadStream(audioFile);
1037
- const fileStat = fs.statSync(audioFile);
1051
+ const fileBlob = await fs.openAsBlob(audioFile);
1038
1052
  const form = new FormData();
1039
- // Use ReadStream directly - Node.js fetch supports it natively for FormData
1040
- form.append('file', fileStream, path.basename(audioFile));
1053
+ form.append('file', fileBlob, path.basename(audioFile));
1041
1054
  form.append('temperature', '0.0');
1042
1055
  form.append('temperature_inc', '0.2');
1043
1056
  form.append('response_format', 'json');
@@ -1359,6 +1372,258 @@ async function processOneTask(row, sheetName, steps, maxRetries, retryDelay, for
1359
1372
  }
1360
1373
 
1361
1374
  // ============================== 主控流程 ==============================
1375
+ // ═══════════════════════════════════════════════════════════════════
1376
+ // 本地文件流水线(--input 模式)
1377
+ // ═══════════════════════════════════════════════════════════════════
1378
+
1379
+ /**
1380
+ * 验证本地视频文件,检测可执行步骤
1381
+ * 返回 { valid, format, hasVideo, hasAudio, videoCodec, audioCodec,
1382
+ * duration, width, height, errors, feasibleSteps }
1383
+ */
1384
+ function validateInputFile(filePath) {
1385
+ const result = {
1386
+ valid: false, format: '', hasVideo: false, hasAudio: false,
1387
+ videoCodec: '', audioCodec: '', duration: 0, width: 0, height: 0,
1388
+ errors: [], feasibleSteps: [],
1389
+ };
1390
+
1391
+ // 1. 文件存在性
1392
+ const absPath = path.resolve(filePath);
1393
+ if (!fs.existsSync(absPath)) {
1394
+ result.errors.push('文件不存在');
1395
+ return result;
1396
+ }
1397
+ const stat = fs.statSync(absPath);
1398
+ if (!stat.isFile()) {
1399
+ result.errors.push('不是一个文件');
1400
+ return result;
1401
+ }
1402
+ if (stat.size === 0) {
1403
+ result.errors.push('文件大小为 0');
1404
+ return result;
1405
+ }
1406
+
1407
+ // 2. ffprobe 分析
1408
+ if (!which(FFPROBE)) {
1409
+ result.errors.push(`ffprobe 不可用 (${FFPROBE})`);
1410
+ result.valid = true; // 文件本身有效,但无法探测流信息
1411
+ result.feasibleSteps = ['transcode', 'transcribe', 'analyze']; // 乐观推测
1412
+ return result;
1413
+ }
1414
+
1415
+ try {
1416
+ const probeRaw = execSync(
1417
+ `${FFPROBE} -v error -show_entries stream=codec_type,codec_name,width,height -show_entries format=format_name,duration -of json "${absPath}"`,
1418
+ { encoding: 'utf-8', timeout: 30000 }
1419
+ );
1420
+ const info = JSON.parse(probeRaw);
1421
+
1422
+ // 提取 format 信息
1423
+ if (info.format) {
1424
+ result.format = (info.format.format_name || '').split(',')[0];
1425
+ result.duration = parseFloat(info.format.duration || '0');
1426
+ }
1427
+
1428
+ // 提取 stream 信息
1429
+ if (info.streams) {
1430
+ for (const s of info.streams) {
1431
+ if (s.codec_type === 'video') {
1432
+ result.hasVideo = true;
1433
+ result.videoCodec = s.codec_name || '';
1434
+ result.width = s.width || 0;
1435
+ result.height = s.height || 0;
1436
+ }
1437
+ if (s.codec_type === 'audio') {
1438
+ result.hasAudio = true;
1439
+ result.audioCodec = s.codec_name || '';
1440
+ }
1441
+ }
1442
+ }
1443
+
1444
+ result.valid = true;
1445
+
1446
+ // 3. 判断可执行步骤
1447
+ if (result.hasVideo) {
1448
+ result.feasibleSteps.push('transcode');
1449
+ }
1450
+ if (result.hasAudio) {
1451
+ result.feasibleSteps.push('transcribe', 'analyze');
1452
+ }
1453
+ // 无视频无音频 → 所有步骤不可行
1454
+ if (!result.hasVideo && !result.hasAudio) {
1455
+ result.feasibleSteps = [];
1456
+ result.errors.push('文件不包含视频或音频流,无法处理');
1457
+ }
1458
+
1459
+ // 如果只有视频没有音频:只能转码
1460
+ if (result.hasVideo && !result.hasAudio) {
1461
+ result.errors.push('文件不含音频轨道,将跳过语音识别和 AI 分析');
1462
+ }
1463
+
1464
+ } catch (e) {
1465
+ result.errors.push(`ffprobe 解析失败: ${(e.stderr || e.message || '').slice(0, 200)}`);
1466
+ result.valid = true; // 文件存在且不为空,让 ffmpeg 自行判断
1467
+ result.feasibleSteps = ['transcode', 'transcribe', 'analyze'];
1468
+ }
1469
+
1470
+ return result;
1471
+ }
1472
+
1473
+ /**
1474
+ * 处理 --input 模式下的文件冲突(已存在的转码/识别结果)
1475
+ * @param {string} proposedPath - 即将生成的输出文件路径
1476
+ * @returns {Promise<{action: 'overwrite'|'skip', path: string}>}
1477
+ */
1478
+ async function resolveInputConflict(proposedPath) {
1479
+ if (!fs.existsSync(proposedPath)) {
1480
+ return { action: 'overwrite', path: proposedPath };
1481
+ }
1482
+ const size = (fs.statSync(proposedPath).size / 1024 / 1024).toFixed(1);
1483
+ console.log(`\n⚠️ 文件已存在: ${proposedPath} (${size} MB)`);
1484
+ const choice = await select({
1485
+ message: '如何处理已有文件?',
1486
+ choices: [
1487
+ { name: '覆盖已有文件 (overwrite)', value: 'overwrite', description: '删除现有文件,重新生成' },
1488
+ { name: '跳过此步骤 (skip)', value: 'skip', description: '保留现有文件,不重新处理' },
1489
+ ],
1490
+ });
1491
+ return { action: choice, path: proposedPath };
1492
+ }
1493
+
1494
+ /**
1495
+ * --input 模式的独立流水线
1496
+ * 不通过 processOneTask(因为它依赖 findDownloadedFile 从 DOWNLOADS_DIR 找文件),
1497
+ * 直接串联 step 函数,保证输入文件路径准确传递。
1498
+ */
1499
+ async function runInputTask(opts) {
1500
+ const {
1501
+ inputPath, stem, sheetName, steps,
1502
+ maxRetries, retryDelay, force,
1503
+ transcodeTimeout, transcribeTimeout, analyzeTimeout,
1504
+ whisperAvailable, fileInfo,
1505
+ } = opts;
1506
+
1507
+ console.log(c('dim', '\n── 开始执行 ──\n'));
1508
+
1509
+ // ── download: 跳过(本地文件)──
1510
+ console.log(` [${stem}] 📥 下载: ${c('yellow', '已跳过 (本地文件)')}`);
1511
+
1512
+ // ── transcode ──
1513
+ let tcFile = null;
1514
+ if (steps.includes('transcode')) {
1515
+ console.log(` [${stem}] 🎵 开始转码...`);
1516
+ try {
1517
+ const { file, error } = await stepTranscode(inputPath, sheetName, maxRetries, retryDelay, force, transcodeTimeout);
1518
+ tcFile = file;
1519
+ if (file && fs.existsSync(file)) {
1520
+ const size = (fs.statSync(file).size / 1024 / 1024).toFixed(1);
1521
+ console.log(` [${stem}] 🎵 转码完成: ${file} (${size} MB)`);
1522
+ } else {
1523
+ console.log(` [${stem}] 🎵 转码: ${c(file ? 'yellow' : 'red', file ? '已跳过 (文件已存在)' : '失败 — ' + (error || ''))}`);
1524
+ }
1525
+ } catch (e) {
1526
+ console.log(` [${stem}] 🎵 转码: ${c('red', '异常 — ' + (e.message || '').slice(0, 200))}`);
1527
+ }
1528
+ if (!tcFile) {
1529
+ console.log(c('yellow', '\n⚠️ 转码未产出文件,后续步骤将跳过\n'));
1530
+ }
1531
+ } else if (steps.includes('transcribe')) {
1532
+ // 无 transcode 步骤但有 transcribe:优先使用已有转码文件
1533
+ const tcDir = path.join(TRANSCODED_DIR, sheetName);
1534
+ const expectedTc = path.join(tcDir, stem + TRANSCODE_EXT);
1535
+ if (fs.existsSync(expectedTc)) {
1536
+ tcFile = expectedTc;
1537
+ console.log(` [${stem}] 🎵 转码: ${c('yellow', '使用已有文件 ' + path.basename(expectedTc))}`);
1538
+ } else {
1539
+ console.log(` [${stem}] 🎵 转码: ${c('red', '未找到转码文件,将尝试用原始文件识别(可能失败)')}`);
1540
+ tcFile = inputPath;
1541
+ }
1542
+ } else {
1543
+ tcFile = inputPath;
1544
+ }
1545
+
1546
+ // ── transcribe ──
1547
+ let transcribeText = '';
1548
+ if (steps.includes('transcribe') && tcFile) {
1549
+ if (!whisperAvailable) {
1550
+ console.log(` [${stem}] 📝 识别: ${c('red', 'whisper 不可用')}`);
1551
+ } else {
1552
+ console.log(` [${stem}] 📝 开始语音识别...`);
1553
+ try {
1554
+ const { text, error } = await stepTranscribe(tcFile, maxRetries, retryDelay, transcribeTimeout);
1555
+ if (text && typeof text === 'string') {
1556
+ transcribeText = text;
1557
+ console.log(` [${stem}] 📝 识别完成: ${text.length} 字符`);
1558
+ } else {
1559
+ console.log(` [${stem}] 📝 识别: ${c('red', '失败 — ' + (error || ''))}`);
1560
+ }
1561
+ } catch (e) {
1562
+ console.log(` [${stem}] 📝 识别: ${c('red', '异常 — ' + (e.message || '').slice(0, 200))}`);
1563
+ }
1564
+ }
1565
+ }
1566
+
1567
+ // ── AI analyze ──
1568
+ let analyzeText = '';
1569
+ if (steps.includes('analyze') && transcribeText) {
1570
+ const aiEnabled = (process.env.AI_ENABLED || 'true').toLowerCase() === 'true';
1571
+ if (aiEnabled) {
1572
+ console.log(` [${stem}] 🤖 开始 AI 分析...`);
1573
+ try {
1574
+ const { text: kw, error } = await stepAnalyze(transcribeText, maxRetries, retryDelay, analyzeTimeout);
1575
+ if (kw && typeof kw === 'string') {
1576
+ analyzeText = kw;
1577
+ console.log(` [${stem}] 🤖 AI分析完成: ${kw.length} 字符`);
1578
+ } else {
1579
+ console.log(` [${stem}] 🤖 AI分析: ${c('red', '失败 — ' + (error || ''))}`);
1580
+ }
1581
+ } catch (e) {
1582
+ console.log(` [${stem}] 🤖 AI分析: ${c('red', '异常 — ' + (e.message || '').slice(0, 200))}`);
1583
+ }
1584
+ } else {
1585
+ console.log(` [${stem}] 🤖 AI分析: ${c('yellow', '已禁用 (AI_ENABLED=false)')}`);
1586
+ }
1587
+ }
1588
+
1589
+ // ── 保存文本结果 ──
1590
+ if (transcribeText || analyzeText) {
1591
+ const outDir = path.join(REPORTS_DIR, 'input-tasks');
1592
+ fs.mkdirSync(outDir, { recursive: true });
1593
+ const outFile = path.join(outDir, `${stem}.txt`);
1594
+ const lines = [
1595
+ `文件: ${inputPath}`,
1596
+ `平台: local`,
1597
+ `文件格式: ${fileInfo.format || 'unknown'}`,
1598
+ `时长: ${fileInfo.duration ? fileInfo.duration.toFixed(1) + 's' : 'unknown'}`,
1599
+ '', '='.repeat(60), '',
1600
+ ];
1601
+ if (transcribeText) {
1602
+ lines.push('【语音识别内容】', '', transcribeText, '');
1603
+ }
1604
+ if (analyzeText) {
1605
+ lines.push('【AI 分析关键词】', '', analyzeText);
1606
+ }
1607
+ fs.writeFileSync(outFile, lines.join('\n'), 'utf-8');
1608
+ console.log(`\n 📄 报告已保存: ${outFile}`);
1609
+ }
1610
+
1611
+ // ── 总结 ──
1612
+ console.log('');
1613
+ const success = [];
1614
+ if (tcFile) success.push('transcode');
1615
+ if (transcribeText) success.push('transcribe');
1616
+ if (analyzeText) success.push('analyze');
1617
+ const failed = steps.filter(s => s !== 'download' && !success.includes(s));
1618
+ if (failed.length === 0) {
1619
+ console.log(c('green', '✅ 全部步骤执行成功'));
1620
+ } else {
1621
+ console.log(c('yellow', `⚠️ ${failed.length} 个步骤未成功: ${failed.join(', ')}`));
1622
+ }
1623
+ console.log('');
1624
+ }
1625
+
1626
+
1362
1627
  // ═══════════════════════════════════════════════════════════════════
1363
1628
  // URL 直链流水线(--url 模式)
1364
1629
  // ═══════════════════════════════════════════════════════════════════
@@ -1823,13 +2088,13 @@ if (process.argv[1] === __filename || process.argv[1]?.endsWith('process_videos.
1823
2088
  .description('视频下载、转码、文本识别、AI分析一体化流程')
1824
2089
  .option('--sheet <name>', '指定 sheet 名称')
1825
2090
  .option('--id <id>', '指定 extra.id 或 title(单条测试)')
1826
- .option('--step <step>', '只执行某一步:download / transcode / transcribe / analyze', (val) => {
2091
+ .option('--step <step>', '指定执行步骤(可多次指定),如 --step transcode --step transcribe', (val, prev) => {
1827
2092
  const allowed = ['download', 'transcode', 'transcribe', 'analyze'];
1828
2093
  if (!allowed.includes(val)) {
1829
2094
  console.error(`Invalid step: ${val}. Must be one of: ${allowed.join(', ')}`);
1830
2095
  process.exit(1);
1831
2096
  }
1832
- return val;
2097
+ return [...(prev || []), val];
1833
2098
  })
1834
2099
  .option('--force', '强制重做下载+转码(忽略已有文件)')
1835
2100
  .option('--concurrency <n>', '并发数,默认 1', parseInt, 1)
@@ -1843,8 +2108,9 @@ if (process.argv[1] === __filename || process.argv[1]?.endsWith('process_videos.
1843
2108
  .option('--retry-failed <path>', '从报告 JSON 重跑失败项')
1844
2109
  .option('--init', '复制 .env.example 到当前目录并重命名为 .env')
1845
2110
  .option('--file <path>', '指定 Excel 文件路径(优先级高于 EXCEL_FILE 环境变量)')
2111
+ .option('--input <path>', '指定本地视频文件路径(跳过下载,直接转码→识别→分析)')
1846
2112
  .option('--url <url>', '直接指定视频下载链接(跳过 Excel),支持标准链接和内嵌链接')
1847
- .option('--name <name>', '指定下载文件名,不含扩展名(与 --url 配合使用)')
2113
+ .option('--name <name>', '指定输出文件名,不含扩展名(与 --url / --input 配合使用)')
1848
2114
  .option('--env-file <path>', '指定要加载的 .env 文件路径(默认: 当前目录 .env)');
1849
2115
 
1850
2116
  program.parse();
@@ -1906,7 +2172,7 @@ if (process.argv[1] === __filename || process.argv[1]?.endsWith('process_videos.
1906
2172
  EXCEL_FILE = path.resolve(opts.file);
1907
2173
  logInfo(`Excel 文件覆盖为: ${EXCEL_FILE}`);
1908
2174
  }
1909
- const steps = opts.step ? [opts.step] : ['download', 'transcode', 'transcribe', 'analyze'];
2175
+ const steps = opts.step?.length ? opts.step : ['download', 'transcode', 'transcribe', 'analyze'];
1910
2176
  // ── --url 模式:直接处理单个视频链接 ──
1911
2177
  if (opts.url) {
1912
2178
  const parsed = parseUrl(opts.url);
@@ -1982,6 +2248,112 @@ if (process.argv[1] === __filename || process.argv[1]?.endsWith('process_videos.
1982
2248
  process.exit(0);
1983
2249
  }
1984
2250
 
2251
+ // ── --input 模式:直接处理本地视频文件 ──
2252
+ if (opts.input) {
2253
+ const inputPath = path.resolve(opts.input);
2254
+ console.log(c('dim', '\n── 文件校验 ──'));
2255
+ console.log(` 文件: ${c('cyan', inputPath)}`);
2256
+
2257
+ const fileInfo = validateInputFile(inputPath);
2258
+ if (!fileInfo.valid) {
2259
+ console.log(c('red', `\n❌ 无法处理该文件:`));
2260
+ for (const e of fileInfo.errors) {
2261
+ console.log(c('red', ` ${e}`));
2262
+ }
2263
+ process.exit(1);
2264
+ }
2265
+
2266
+ // 展示文件信息
2267
+ console.log(` 格式: ${c('cyan', fileInfo.format || 'unknown')}`);
2268
+ if (fileInfo.hasVideo) {
2269
+ console.log(` 视频: ${c('cyan', fileInfo.videoCodec)} ${fileInfo.width}x${fileInfo.height}`);
2270
+ }
2271
+ if (fileInfo.hasAudio) {
2272
+ console.log(` 音频: ${c('cyan', fileInfo.audioCodec)}`);
2273
+ }
2274
+ if (fileInfo.duration > 0) {
2275
+ const dur = fileInfo.duration;
2276
+ const mm = Math.floor(dur / 60);
2277
+ const ss = Math.floor(dur % 60);
2278
+ console.log(` 时长: ${c('cyan', `${mm}:${String(ss).padStart(2, '0')}`)} (${dur.toFixed(1)}s)`);
2279
+ }
2280
+ if (fileInfo.errors.length > 0) {
2281
+ console.log('');
2282
+ for (const e of fileInfo.errors) {
2283
+ console.log(c('yellow', ` ⚠️ ${e}`));
2284
+ }
2285
+ }
2286
+
2287
+ // 展示可执行步骤
2288
+ const defaultSteps = fileInfo.feasibleSteps;
2289
+ // 用户可通过 --step 指定步骤,但只保留可行的
2290
+ let steps;
2291
+ if (opts.step?.length) {
2292
+ steps = opts.step.filter(s => defaultSteps.includes(s));
2293
+ if (steps.length === 0) {
2294
+ console.log(c('yellow', `\n⚠️ --step ${opts.step.join(', ')} 不可行(文件不支持)\n`));
2295
+ process.exit(1);
2296
+ }
2297
+ } else {
2298
+ steps = defaultSteps;
2299
+ }
2300
+ console.log(`\n 可执行步骤: ${c('green', steps.join(' → '))}`);
2301
+
2302
+ // 确定输出文件名
2303
+ const sheetName = 'local';
2304
+ const baseName = opts.name || path.parse(inputPath).name;
2305
+ const stem = baseName;
2306
+
2307
+ // 检查转码输出文件是否已有冲突
2308
+ if (steps.includes('transcode') && !opts.force) {
2309
+ const tcDir = path.join(TRANSCODED_DIR, sheetName);
2310
+ const tcPath = path.join(tcDir, stem + TRANSCODE_EXT);
2311
+ const conflict = await resolveInputConflict(tcPath);
2312
+ if (conflict.action === 'skip') {
2313
+ console.log(c('yellow', '\n⏭️ 已跳过转码\n'));
2314
+ steps = steps.filter(s => s !== 'transcode');
2315
+ }
2316
+ }
2317
+
2318
+ if (steps.length === 0) {
2319
+ console.log(c('yellow', '\n无剩余步骤可执行\n'));
2320
+ process.exit(0);
2321
+ }
2322
+
2323
+ // 确保目录存在
2324
+ if (steps.includes('transcode')) {
2325
+ fs.mkdirSync(path.join(TRANSCODED_DIR, sheetName), { recursive: true });
2326
+ }
2327
+
2328
+ // 检查 whisper 可用性
2329
+ let whisperAvailable = false;
2330
+ if (steps.includes('transcribe')) {
2331
+ whisperAvailable = await checkWhisperAvailable();
2332
+ if (!whisperAvailable) {
2333
+ const backend = WHISPER_BACKEND === 'local' ? 'local CLI' : WHISPER_SERVICE;
2334
+ logWarn(`⚠️ whisper not available (${backend}), transcribe step will fail`);
2335
+ }
2336
+ }
2337
+
2338
+ // 执行流水线
2339
+ await runInputTask({
2340
+ inputPath,
2341
+ stem,
2342
+ sheetName,
2343
+ steps,
2344
+ maxRetries: opts.retry,
2345
+ retryDelay: opts.retryDelay,
2346
+ force: opts.force || false,
2347
+ transcodeTimeout: opts.transcodeTimeout,
2348
+ transcribeTimeout: opts.transcribeTimeout,
2349
+ analyzeTimeout: opts.analyzeTimeout,
2350
+ whisperAvailable,
2351
+ fileInfo,
2352
+ });
2353
+
2354
+ process.exit(0);
2355
+ }
2356
+
1985
2357
 
1986
2358
  run({
1987
2359
  targetSheet: opts.sheet || null,