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 +5 -7
- package/README.md +185 -46
- package/package.json +1 -1
- package/process_videos.js +380 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,20 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## [1.0
|
|
3
|
+
## [1.1.0] - 2026-06-11
|
|
4
4
|
|
|
5
5
|
### Features
|
|
6
6
|
|
|
7
|
-
-
|
|
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
|
-
-
|
|
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
|
-
## [
|
|
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
|
-
#
|
|
1
|
+
# 视频处理流水线 (Video Pipeline)
|
|
2
2
|
|
|
3
|
-
基于 `process_videos.py
|
|
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.
|
|
114
|
-
├── .
|
|
115
|
-
├── .
|
|
116
|
-
├──
|
|
117
|
-
├──
|
|
118
|
-
|
|
119
|
-
│ └──
|
|
120
|
-
├──
|
|
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/
|
|
158
|
+
├── transcoded/ # ffmpeg 转码输出(wav 16kHz mono)
|
|
124
159
|
│ ├── YouTube视频/
|
|
125
160
|
│ └── 普诺赛中文站/
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
224
|
+
node process_videos.js --sheet "普诺赛中文站" --id 16 --step transcode
|
|
177
225
|
|
|
178
226
|
# 只跑识别(需要已有转码文件)
|
|
179
|
-
|
|
227
|
+
node process_videos.js --sheet "普诺赛中文站" --id 16 --step transcribe
|
|
180
228
|
|
|
181
229
|
# 只跑 AI 分析(需要已有识别文本)
|
|
182
|
-
|
|
230
|
+
node process_videos.js --sheet "普诺赛中文站" --id 16 --step analyze
|
|
183
231
|
|
|
184
232
|
# 强制重新下载(忽略已有文件)
|
|
185
|
-
|
|
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
|
-
|
|
240
|
+
node process_videos.js --concurrency 2 --retry 3
|
|
193
241
|
|
|
194
242
|
# 只跑某一 sheet
|
|
195
|
-
|
|
243
|
+
node process_videos.js --sheet "YouTube视频" --concurrency 2 --retry 3
|
|
196
244
|
|
|
197
245
|
# 先干跑预览
|
|
198
|
-
|
|
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
|
-
|
|
254
|
+
node process_videos.js --retry-failed reports/report_20260610_143000.json --dry-run
|
|
207
255
|
|
|
208
256
|
# 重跑:
|
|
209
|
-
|
|
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
|
-
|
|
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
|
|
253
|
-
| `--id
|
|
254
|
-
| `--step
|
|
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
|
|
257
|
-
| `--retry
|
|
258
|
-
| `--retry-delay
|
|
259
|
-
| `--download-timeout
|
|
260
|
-
| `--transcode-timeout
|
|
261
|
-
| `--transcribe-timeout
|
|
262
|
-
| `--analyze-timeout
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
551
|
+
node process_videos.js --sheet "YouTube视频" --id 2143 --retry 2
|
|
449
552
|
|
|
450
553
|
# 3. 全量执行
|
|
451
|
-
|
|
554
|
+
node process_videos.js --concurrency 3 --retry 3
|
|
452
555
|
|
|
453
556
|
# 4. 查看报告,重跑失败项
|
|
454
|
-
|
|
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
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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. `
|
|
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
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
|
|
1037
|
-
const fileStat = fs.statSync(audioFile);
|
|
1051
|
+
const fileBlob = await fs.openAsBlob(audioFile);
|
|
1038
1052
|
const form = new FormData();
|
|
1039
|
-
|
|
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>', '
|
|
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>', '
|
|
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 ?
|
|
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,
|