@optima-chat/gen-cli 2.0.0 → 2.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/.claude/skills/digital-human/SKILL.md +195 -0
- package/.claude/skills/digital-human/references/avatar-catalog.md +47 -0
- package/.claude/skills/digital-human/references/edit.md +198 -0
- package/.claude/skills/digital-human/references/generate.md +246 -0
- package/.claude/skills/digital-human/references/manage.md +126 -0
- package/.claude/skills/digital-human/references/train.md +291 -0
- package/.claude/skills/gen/SKILL.md +13 -8
- package/.claude/skills/video-edit/SKILL.md +99 -17
- package/.claude/skills/video-gen/SKILL.md +34 -108
- package/.claude/skills/video-translate/SKILL.md +254 -11
- package/dist/commands/video.d.ts +19 -0
- package/dist/commands/video.d.ts.map +1 -1
- package/dist/commands/video.js +274 -0
- package/dist/commands/video.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: video-translate
|
|
3
3
|
description: "把单段口播视频翻译成 English / Thai (Thailand) / Malay (Malaysia) / Vietnamese (Vietnam) 之一,出片自带译音(自动克隆原说话人)+ 烧录目标语花体字幕 + 默认 BGM ducking。触发场景:用户说翻译这个视频/做泰语版/英文版/越南语版/马来语版/视频本地化/换语言/dub。"
|
|
4
|
-
version: 1.0.
|
|
4
|
+
version: 1.0.3
|
|
5
5
|
owner_repo: Optima-Chat/optima-gen
|
|
6
6
|
---
|
|
7
7
|
|
|
@@ -21,7 +21,7 @@ owner_repo: Optima-Chat/optima-gen
|
|
|
21
21
|
## 前提
|
|
22
22
|
|
|
23
23
|
- 源视频 URL 公网可访问(或先让用户上传到 Optima → 拿 URL)
|
|
24
|
-
- 容器有:`gen` CLI(`@optima-chat/optima-gen` ≥ latest)、`video-translate` CLI(`@optima-chat/video-translate-tools` ≥ 1.0.
|
|
24
|
+
- 容器有:`gen` CLI(`@optima-chat/optima-gen` ≥ latest)、`video-translate` CLI(`@optima-chat/video-translate-tools` ≥ 1.0.5)、`ffmpeg`、`curl`、`jq`
|
|
25
25
|
- gen video-translate 的 `--mode fast` / `--dynamic-duration` flag 在 latest 版本已存在;若 CLI 报 unknown flag → `npm i -g @optima-chat/optima-gen@latest` 升级
|
|
26
26
|
|
|
27
27
|
## 输入
|
|
@@ -33,13 +33,33 @@ owner_repo: Optima-Chat/optima-gen
|
|
|
33
33
|
| `TAG` | ✅ | 对应的两字母 tag(`en` / `th` / `ms` / `vi`) |
|
|
34
34
|
| `BGM` | ⬜ | 自定义 BGM 文件路径(覆盖默认)。不传则用 npm 包内置 `bgm/default.mp3`(22s clean instrumental,自动 loop)|
|
|
35
35
|
| `NO_BGM` | ⬜ | 设非空值则跳过 BGM(出片只有人声 + 原视频 BGM 残留)|
|
|
36
|
+
| `VOICE` | ⬜ | HeyGen stock voice_id(从下面 ## Voice Catalog 选)。**不传 = 克隆源说话人音色**(legacy 默认行为)|
|
|
36
37
|
| `NAME` | ⬜ | 工作区名,默认从 URL 末段推 |
|
|
37
38
|
|
|
38
39
|
## 3 步主流程(+ Step 0 预检 + Step 4 可选清理)
|
|
39
40
|
|
|
40
|
-
### Step 0:声明 + 工作区 + 源视频音量预检
|
|
41
|
+
### Step 0:声明 + 工作区 + URL 预处理 + 源视频音量预检
|
|
41
42
|
|
|
42
43
|
```bash
|
|
44
|
+
## ⚠ URL 必须是公网 https URL,不能是本地路径 /home/aiuser/...
|
|
45
|
+
## gen video-translate 只接受 https URL(HeyGen 那边要拉的)
|
|
46
|
+
## 如果是本地路径,先用 chat 系统的 file API 签 URL
|
|
47
|
+
if [[ ! "$URL" =~ ^https?:// ]]; then
|
|
48
|
+
echo "INFO: 本地路径 '$URL',需要上传拿 https URL"
|
|
49
|
+
TOKEN=$(jq -r '.access_token' ~/.optima/token.json)
|
|
50
|
+
## shell.optima.onl 的 file signing endpoint(具体路径以系统实际为准,
|
|
51
|
+
## 这里给出常见模式;如果不行,根据 stderr 报错调整)
|
|
52
|
+
SIGNED=$(curl -sS -X POST "https://shell.optima.onl/api/files/sign" \
|
|
53
|
+
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
|
54
|
+
-d "{\"path\":\"$URL\"}" 2>&1 | jq -r '.data.url // .url // empty' 2>/dev/null)
|
|
55
|
+
if [ -z "$SIGNED" ]; then
|
|
56
|
+
echo "ERR: 拿不到 signed URL,把 '$URL' 上传到 chat 系统拿 https URL 再来"
|
|
57
|
+
exit 1
|
|
58
|
+
fi
|
|
59
|
+
URL="$SIGNED"
|
|
60
|
+
echo "URL → $URL"
|
|
61
|
+
fi
|
|
62
|
+
|
|
43
63
|
## URL 末段可能含 query string(HeyGen / S3 预签名 url 含 token)— 先去掉再 basename
|
|
44
64
|
NAME="${NAME:-$(echo "$URL" | cut -d'?' -f1 | xargs basename | sed 's/\.[^.]*$//' | sed 's/[^A-Za-z0-9_-]/_/g')}"
|
|
45
65
|
WORK="./videos/${NAME}.work"
|
|
@@ -52,6 +72,42 @@ ffmpeg -i "$URL" -af volumedetect -f null - 2>&1 | grep mean_volume
|
|
|
52
72
|
|
|
53
73
|
如果 `mean_volume < -25dB`,提示用户:"源视频音量太低(< −25dB),翻译服务大概率会报无人声。建议先用 `ffmpeg -i in.mp4 -af 'volume=20dB,acompressor=threshold=-20dB:ratio=4' -c:v copy out.mp4` 放大后再翻译。"
|
|
54
74
|
|
|
75
|
+
### Step 0.5:让用户挑音色 — 纯文本对话(不要用 AskUserQuestion)
|
|
76
|
+
|
|
77
|
+
**重要:不要用 AskUserQuestion 工具**(只支持 4 选项 + label-based 反查不可靠,试过失败)。**用纯文本输出 5 个选项,等用户文字回复**:
|
|
78
|
+
|
|
79
|
+
agent 输出(逐字照搬,把 $LANG 换成实际目标语):
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
翻译到 $LANG,4 个语种共用一个音色。请回复编号(1-5)或名字:
|
|
83
|
+
|
|
84
|
+
1. Connie - 沉稳专业旁白(F) — preview: https://resource.heygen.ai/text_to_speech/locale=en-USmodel=eleven_multilingual_v2id=9FnNGNtwCeU9fyf6mFfDp8.mp3
|
|
85
|
+
2. Sophie - 温柔友好(F) — preview: https://resource.heygen.ai/text_to_speech/locale=model=eleven_multilingual_v2id=kte4EzDuRTnsnHkATe6tDK.mp3
|
|
86
|
+
3. Bruce - 中年浑厚(M) — preview: https://resource.heygen.ai/text_to_speech/locale=model=eleven_multilingual_v2id=2SdnapPUN7wvtCbkPSgdHV.mp3
|
|
87
|
+
4. Luca - 年轻活力(M) — preview: https://resource.heygen.ai/text_to_speech/locale=model=eleven_multilingual_v2id=FVKYscu8J8EVReBuZdPXnJ.mp3
|
|
88
|
+
5. 保留原声(克隆源说话人音色)
|
|
89
|
+
|
|
90
|
+
回复方式:数字 1-5,或者 "Connie"/"Sophie"/"Bruce"/"Luca"/"原声",或者 "随便"(我帮你随机挑)。
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
#### 用户回复 → VOICE 变量(严格按下表)
|
|
94
|
+
|
|
95
|
+
| 用户回复 | VOICE 变量值 |
|
|
96
|
+
|---|---|
|
|
97
|
+
| `1` / `Connie` / `用 Connie` / 任何含 "Connie" | **`VOICE=d774d69075f24d1fb52a0dad145ba809`** |
|
|
98
|
+
| `2` / `Sophie` / `用 Sophie` | **`VOICE=vakjM0uzzAxU4UiT0433`** |
|
|
99
|
+
| `3` / `Bruce` / `用 Bruce` | **`VOICE=1LtsDD7yfTuX92TzjmJk`** |
|
|
100
|
+
| `4` / `Luca` / `用 Luca` | **`VOICE=6HiVdeiuBdZbtcnukrQn`** |
|
|
101
|
+
| `5` / `原声` / `克隆` / `用我自己的` / `keep original` | **`VOICE=`**(留空,不传 `--voice`) |
|
|
102
|
+
| `随便` / `random` | 从 1-4 voice_id 里**随机挑一个**设 VOICE,**不要默认 5** |
|
|
103
|
+
|
|
104
|
+
#### 严禁(踩过两次了)
|
|
105
|
+
|
|
106
|
+
- ❌ **用 AskUserQuestion 工具**(4 选项限制 + label 反查不可靠,会让用户选了 voice 你跑 clone)
|
|
107
|
+
- ❌ **用户选了 1-4,你设 VOICE=空走 clone** — 跟选择不一致就是 bug
|
|
108
|
+
- ❌ **不列全 5 个选项**(比如只列"用 voice / 原声"二选一,等同不让选)
|
|
109
|
+
- ❌ **不告诉用户怎么回复**(必须明确说"回复数字或名字")
|
|
110
|
+
|
|
55
111
|
### Step 1:HeyGen 翻译(用现成 CLI)
|
|
56
112
|
|
|
57
113
|
```bash
|
|
@@ -62,6 +118,7 @@ gen video-translate \
|
|
|
62
118
|
--lang "$LANG" \
|
|
63
119
|
--mode fast \
|
|
64
120
|
--dynamic-duration \
|
|
121
|
+
${VOICE:+--voice "$VOICE"} \
|
|
65
122
|
-o "$RAW_DIR" \
|
|
66
123
|
> "$WORK/gen.json"
|
|
67
124
|
```
|
|
@@ -70,14 +127,14 @@ gen video-translate \
|
|
|
70
127
|
|
|
71
128
|
幂等:`$WORK/gen.json` 存在则跳。
|
|
72
129
|
|
|
73
|
-
|
|
130
|
+
**⚠ 提取字段必须用 `.data.` 前缀**:gen-cli 的 `outputSuccess` 把所有字段包装在 `{success: true, data: {...}}` 结构里。**不带 `.data.` 永远读不到字段,SKILL 会卡死**:
|
|
74
131
|
|
|
75
132
|
```bash
|
|
76
|
-
##
|
|
77
|
-
AUDIO_URL=$(jq -r '.audio_url // empty' "$WORK/gen.json")
|
|
78
|
-
CAP_URL=$(jq -r '.caption_url // empty' "$WORK/gen.json")
|
|
133
|
+
## gen-cli 输出格式: {"success": true, "data": {"task_id": "...", "audio_url": "...", "caption_url": "..."}}
|
|
134
|
+
AUDIO_URL=$(jq -r '.data.audio_url // empty' "$WORK/gen.json")
|
|
135
|
+
CAP_URL=$(jq -r '.data.caption_url // empty' "$WORK/gen.json")
|
|
79
136
|
[ -n "$AUDIO_URL" ] && [ -n "$CAP_URL" ] || {
|
|
80
|
-
echo "ERR: gen.json 缺 .audio_url 或 .caption_url
|
|
137
|
+
echo "ERR: gen.json 缺 .data.audio_url 或 .data.caption_url 字段。原始 response:"
|
|
81
138
|
cat "$WORK/gen.json"
|
|
82
139
|
exit 1
|
|
83
140
|
}
|
|
@@ -132,6 +189,14 @@ video-translate mux \
|
|
|
132
189
|
- `--raw` = HeyGen v3 译音(audio_url 下载的 wav)
|
|
133
190
|
- 字幕烧 + BGM ducking 走 mux 内置
|
|
134
191
|
|
|
192
|
+
**BGM + 花体字幕样式 = 默认产出的一部分,不是可选项。** 不要问用户"要不要加",不要在结尾说"如需 BGM/字幕样式可补充"。`video-translate mux` 默认就用 `bgm/default.mp3` + Path-B 描边样式;`render-ass --lang $TAG` 默认按语言选字体(Bangers / Sarabun / Noto Sans)。不要绕过 `mux` 自己手写 ffmpeg(会丢 BGM + 字幕样式 + 音轨规范化)。
|
|
193
|
+
|
|
194
|
+
如果 `mux` 真的失败,先把完整 stderr 给用户看(不要默默用 `ffmpeg -i ... -c copy` 兜底出"裸版"),让用户决定下一步。
|
|
195
|
+
|
|
196
|
+
## 出片汇报口径
|
|
197
|
+
|
|
198
|
+
只描述事实("xxx_th.mp4 翻译完成,泰语花体字幕 + BGM ducking 已烧录")。**禁止**任何形式的"如需 X 可以告诉我"尾巴 — BGM 和字幕样式不是可选 add-on,这种话术暗示没做。
|
|
199
|
+
|
|
135
200
|
### Step 4(可选):清理
|
|
136
201
|
|
|
137
202
|
```bash
|
|
@@ -139,6 +204,170 @@ video-translate mux \
|
|
|
139
204
|
rm -rf "$WORK"
|
|
140
205
|
```
|
|
141
206
|
|
|
207
|
+
---
|
|
208
|
+
|
|
209
|
+
## 批量模式(多语种,一段视频翻多语)
|
|
210
|
+
|
|
211
|
+
**触发条件**:用户一次请求 ≥2 种目标语言("翻译成英/泰/越/马 4 国语")。
|
|
212
|
+
|
|
213
|
+
**不要按单语流程循环跑 N 次**——会有两个致命问题:
|
|
214
|
+
1. wall time = N × 单次 (~12min/lang × 4 = 48min)
|
|
215
|
+
2. chat bash 队列被 N 条阻塞命令塞满,后续命令(mux/SFX)全卡"准备中"
|
|
216
|
+
|
|
217
|
+
改用 **单条 bash 内 N 路 subshell 并发**:chat 队列只占 1 slot,HeyGen 服务端并行处理,wall time ≈ 单次最慢 (~12min)。
|
|
218
|
+
|
|
219
|
+
### Step B0:URL 预处理 + workspace + 音量预检 + 源视频下载(只跑一次)
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
## ⚠ URL 预处理:同单语 Step 0,本地路径必须先上传拿 https URL
|
|
223
|
+
if [[ ! "$URL" =~ ^https?:// ]]; then
|
|
224
|
+
TOKEN=$(jq -r '.access_token' ~/.optima/token.json)
|
|
225
|
+
SIGNED=$(curl -sS -X POST "https://shell.optima.onl/api/files/sign" \
|
|
226
|
+
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
|
227
|
+
-d "{\"path\":\"$URL\"}" 2>&1 | jq -r '.data.url // .url // empty' 2>/dev/null)
|
|
228
|
+
if [ -z "$SIGNED" ]; then
|
|
229
|
+
echo "ERR: 拿不到 signed URL for '$URL'"; exit 1
|
|
230
|
+
fi
|
|
231
|
+
URL="$SIGNED"
|
|
232
|
+
fi
|
|
233
|
+
|
|
234
|
+
NAME="${NAME:-$(echo "$URL" | cut -d'?' -f1 | xargs basename | sed 's/\.[^.]*$//' | sed 's/[^A-Za-z0-9_-]/_/g')}"
|
|
235
|
+
mkdir -p "./videos/${NAME}.batch"
|
|
236
|
+
|
|
237
|
+
## 音量预检(同单语 Step 0)
|
|
238
|
+
ffmpeg -i "$URL" -af volumedetect -f null - 2>&1 | grep mean_volume
|
|
239
|
+
|
|
240
|
+
## 源视频下载一次,所有 mux 共用(避免 N 次重复下载)
|
|
241
|
+
ORIG_VIDEO="./videos/${NAME}.batch/orig.mp4"
|
|
242
|
+
[ -f "$ORIG_VIDEO" ] || curl -sSL "$URL" -o "$ORIG_VIDEO"
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### Step B0.5:voice 选一次,所有语种共用
|
|
246
|
+
|
|
247
|
+
同单语 Step 0.5。`VOICE` 变量设一次后下方循环里所有 subshell 都继承,**所有语种用同一个 voice 出片**(用户只需挑一次)。
|
|
248
|
+
|
|
249
|
+
### Step B1:并发派出所有 HeyGen 翻译(后台跑,~5 秒返回)
|
|
250
|
+
|
|
251
|
+
```bash
|
|
252
|
+
## 用户实际要的语种,从下面 4 个里选(不要的注释掉)
|
|
253
|
+
LANG_LIST=(
|
|
254
|
+
"English:en"
|
|
255
|
+
"Thai (Thailand):th"
|
|
256
|
+
"Malay (Malaysia):ms"
|
|
257
|
+
"Vietnamese (Vietnam):vi"
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
for entry in "${LANG_LIST[@]}"; do
|
|
261
|
+
LANG="${entry%:*}"
|
|
262
|
+
TAG="${entry##*:}"
|
|
263
|
+
WORK="./videos/${NAME}_${TAG}.work"
|
|
264
|
+
mkdir -p "$WORK/raw"
|
|
265
|
+
|
|
266
|
+
## 后台启动 gen video-translate,各自写 gen.json,bash 立即继续
|
|
267
|
+
nohup gen video-translate \
|
|
268
|
+
--video-url "$URL" --lang "$LANG" \
|
|
269
|
+
--mode fast --dynamic-duration \
|
|
270
|
+
${VOICE:+--voice "$VOICE"} \
|
|
271
|
+
-o "$WORK/raw" > "$WORK/gen.json" 2>&1 &
|
|
272
|
+
|
|
273
|
+
echo "$TAG: launched (PID $!) -> $WORK/gen.json"
|
|
274
|
+
done
|
|
275
|
+
echo "=== all submitted, returning immediately ==="
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
这一步 5 秒返回。**关键**:用 `nohup ... &` 后台启动,bash exit 后进程继续(尽量 survive 短暂 idle / 容器升级)。每个进程会在 HeyGen 翻译完成后把 audio_url/caption_url 写入对应的 gen.json。
|
|
279
|
+
|
|
280
|
+
### Step B2-B5:逐语种等结果 → 出片(每个一条独立 bash)
|
|
281
|
+
|
|
282
|
+
**重点:每个语种用单独的 bash 命令处理,不要再用 `&` 并发**。这样:
|
|
283
|
+
- chat 看到每个语种独立完成、独立报告
|
|
284
|
+
- 第 1 个 bash 等 HeyGen(~12 min),后续每个 bash 几乎瞬间完成(因为 4 个翻译是并行跑的,后续几个早已 done)
|
|
285
|
+
- 失败隔离:1 个失败不影响其他
|
|
286
|
+
|
|
287
|
+
通用 per-lang 模板(把 `$TAG` 换成 en / th / ms / vi 各跑一遍):
|
|
288
|
+
|
|
289
|
+
```bash
|
|
290
|
+
TAG=en ## ← 换语种就改这个
|
|
291
|
+
WORK="./videos/${NAME}_${TAG}.work"
|
|
292
|
+
FINAL="./videos/${NAME}_${TAG}.mp4"
|
|
293
|
+
|
|
294
|
+
## 等 gen.json 写完(或失败)
|
|
295
|
+
## ⚠ gen-cli 用 outputSuccess 包装在 {success, data: {...}} 里,字段在 .data.* 下
|
|
296
|
+
DEADLINE=$(( $(date +%s) + 1800 )) ## 30min 上限,跟 HeyGen poll timeout 对齐
|
|
297
|
+
while true; do
|
|
298
|
+
## Case 1: 成功 → .data.audio_url 非 null
|
|
299
|
+
if jq -e '.data.audio_url' "$WORK/gen.json" >/dev/null 2>&1; then
|
|
300
|
+
break
|
|
301
|
+
fi
|
|
302
|
+
## Case 2: 业务失败 → .data.error_message(outputSuccess with status=failed)
|
|
303
|
+
if jq -e '.data.error_message' "$WORK/gen.json" >/dev/null 2>&1; then
|
|
304
|
+
echo "[$TAG] FAIL: $(jq -r '.data.error_message' "$WORK/gen.json")"
|
|
305
|
+
exit 1
|
|
306
|
+
fi
|
|
307
|
+
## Case 3: CLI 异常 → .error.code/.error.message(outputError 抛出)
|
|
308
|
+
if jq -e '.error' "$WORK/gen.json" >/dev/null 2>&1; then
|
|
309
|
+
echo "[$TAG] FAIL: $(jq -r '.error.code + \": \" + .error.message' "$WORK/gen.json")"
|
|
310
|
+
exit 1
|
|
311
|
+
fi
|
|
312
|
+
## Case 4: 30min 超时(HeyGen 一般 5-15min,30min 还没就是真挂了)
|
|
313
|
+
if [ "$(date +%s)" -gt "$DEADLINE" ]; then
|
|
314
|
+
echo "[$TAG] TIMEOUT 30min,gen.json 内容:"; cat "$WORK/gen.json"; exit 1
|
|
315
|
+
fi
|
|
316
|
+
sleep 15
|
|
317
|
+
done
|
|
318
|
+
|
|
319
|
+
## 拿到 audio_url + caption_url(必须带 .data. 前缀)
|
|
320
|
+
AUDIO_URL=$(jq -r '.data.audio_url' "$WORK/gen.json")
|
|
321
|
+
CAP_URL=$(jq -r '.data.caption_url' "$WORK/gen.json")
|
|
322
|
+
|
|
323
|
+
## 下载 + render-ass
|
|
324
|
+
curl -sSL --retry 1 "$AUDIO_URL" -o "$WORK/translated_audio.wav"
|
|
325
|
+
curl -sSL --retry 1 "$CAP_URL" -o "$WORK/caption.srt"
|
|
326
|
+
video-translate render-ass \
|
|
327
|
+
--srt "$WORK/caption.srt" --lang "$TAG" \
|
|
328
|
+
--translations "$WORK/translations.json" \
|
|
329
|
+
--out "$WORK/subs.ass"
|
|
330
|
+
|
|
331
|
+
## mux 出片
|
|
332
|
+
video-translate mux \
|
|
333
|
+
--raw "$WORK/translated_audio.wav" \
|
|
334
|
+
--orig-video "$ORIG_VIDEO" \
|
|
335
|
+
--ass "$WORK/subs.ass" \
|
|
336
|
+
${BGM:+--bgm "$BGM"} ${NO_BGM:+--no-bgm} \
|
|
337
|
+
--work "$WORK" \
|
|
338
|
+
--out "$FINAL"
|
|
339
|
+
|
|
340
|
+
echo "[$TAG] DONE -> $FINAL"
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
**Agent 执行节奏**:
|
|
344
|
+
1. 跑 Step B1(1 条 bash,5 秒返回,**立刻告知用户"4 个翻译派出去了"**)
|
|
345
|
+
2. 跑 per-lang 模板 `TAG=en`(1 条 bash,~12 min,出片后**立刻告知用户"English 出片 ✓"**)
|
|
346
|
+
3. 跑 per-lang 模板 `TAG=th`(1 条 bash,~30 秒,出片后**立刻告知用户"Thai 出片 ✓"**)
|
|
347
|
+
4. 跑 per-lang 模板 `TAG=ms`(~30 秒)
|
|
348
|
+
5. 跑 per-lang 模板 `TAG=vi`(~30 秒)
|
|
349
|
+
|
|
350
|
+
**总 wall time 不变(~13 min)**,但用户每 30 秒看到一个出片,不是 12 min 黑盒后突然 4 个一起出。
|
|
351
|
+
|
|
352
|
+
输出:`./videos/${NAME}_en.mp4` / `_th.mp4` / `_ms.mp4` / `_vi.mp4`
|
|
353
|
+
|
|
354
|
+
### 单语 vs 渐进批量对比
|
|
355
|
+
|
|
356
|
+
| | 单语循环(❌ 不要) | 渐进批量(✓) |
|
|
357
|
+
|---|---|---|
|
|
358
|
+
| 源视频下载 | N 次重下 | 1 次共用 |
|
|
359
|
+
| HeyGen 调用 | 串行 N × 12min | 并发 max(12min) |
|
|
360
|
+
| chat 队列占 | N 条阻塞 bash | 1 条 submit + N 条快速 poll |
|
|
361
|
+
| 失败隔离 | 一个挂全停 | per-lang subshell + bash 独立 |
|
|
362
|
+
| 用户感知 | 黑盒 48 min | 第 1 个 ~12 min 出,后续每 ~30 秒 1 个 |
|
|
363
|
+
| **wall time(N=4)** | **~48 min** | **~13 min(73%↓)** |
|
|
364
|
+
|
|
365
|
+
### 失败处理
|
|
366
|
+
|
|
367
|
+
- 单 lang 失败:per-lang bash `exit 1`,agent 报告该语种失败但**继续跑下一个语种的 bash**
|
|
368
|
+
- 用户对失败的单 lang re-run → 删 `$WORK/gen.json` 重跑 Step B1 单语 + per-lang 模板即可
|
|
369
|
+
- HeyGen credit 不足报错 → 当 lang 个失败处理,不阻塞其他 lang
|
|
370
|
+
|
|
142
371
|
## 错误处理
|
|
143
372
|
|
|
144
373
|
| 失败 | 处理 |
|
|
@@ -148,15 +377,14 @@ rm -rf "$WORK"
|
|
|
148
377
|
| HeyGen `status: failed` "No speaker is detected" | 同源视频音量过低处理 |
|
|
149
378
|
| HeyGen 其他失败 | 透出 task_id,提示用 `gen task get <id>` 查最新;干净退出 |
|
|
150
379
|
| HeyGen >30min 超时 | 同上,可能任务还在 running |
|
|
151
|
-
| `gen.json` 缺 `.
|
|
380
|
+
| `gen.json` 缺 `.audio_url` / `.caption_url` | gen 后端契约可能改字段名。打 `cat $WORK/gen.json` 看实际字段 |
|
|
152
381
|
| `curl <caption_url>` 失败 | URL 7 天 expire。retry 1 次后仍失败 → 重跑 step 1 |
|
|
153
382
|
| `video-translate render-ass` SRT 解析失败 | 显示 SRT 头 20 行,不重试 |
|
|
154
383
|
| `video-translate mux` 字体 ☐ | 检查 `fc-match Bangers / Sarabun / "Noto Sans"` 是否精确返回 |
|
|
155
384
|
|
|
156
385
|
## 不做
|
|
157
386
|
|
|
158
|
-
- ❌
|
|
159
|
-
- ❌ 性别自动检测 — 同上 defer v2
|
|
387
|
+
- ❌ 性别自动检测(让用户自己选,见 Step 0.5)
|
|
160
388
|
- ❌ 硬字幕(burnt-in)抹除 — 源视频有的话出片会双语
|
|
161
389
|
- ❌ 自动加粗关键词 — SRT 直入无粉色,用户手编 translations.json
|
|
162
390
|
- ❌ 双说话人差异化音色 — HeyGen 自动 diarize 但用同一克隆音色
|
|
@@ -166,3 +394,18 @@ rm -rf "$WORK"
|
|
|
166
394
|
|
|
167
395
|
- 设计 SPEC:https://github.com/Optima-Chat/video-translate/blob/main/SPEC.md (v3.1)
|
|
168
396
|
- 实测踩坑(gotchas):同上 §Gotchas
|
|
397
|
+
|
|
398
|
+
---
|
|
399
|
+
|
|
400
|
+
## Voice Catalog
|
|
401
|
+
|
|
402
|
+
下面 4 个 voice 全部是 HeyGen catalog 里走 **ElevenLabs `eleven_multilingual_v2`** 模型的多语 voice,**任选一个,所有支持语言(en/th/ms/vi)都能说**。实测 Connie 跨 4 语全部 Whisper 95%+ 语种识别置信度,音色保留 + 自动适配目标语言性别敬语(如泰语 ค่ะ/ครับ)。
|
|
403
|
+
|
|
404
|
+
| # | voice_id | 名字 | 性别 | 风格定位 | preview(英文样本) |
|
|
405
|
+
|---|---|---|---|---|---|
|
|
406
|
+
| 1 | `d774d69075f24d1fb52a0dad145ba809` | Connie - Professional | F | 沉稳专业旁白 | https://resource.heygen.ai/text_to_speech/locale=en-USmodel=eleven_multilingual_v2id=9FnNGNtwCeU9fyf6mFfDp8.mp3 |
|
|
407
|
+
| 2 | `vakjM0uzzAxU4UiT0433` | Sophie | F | 温柔友好 | https://resource.heygen.ai/text_to_speech/locale=model=eleven_multilingual_v2id=kte4EzDuRTnsnHkATe6tDK.mp3 |
|
|
408
|
+
| 3 | `1LtsDD7yfTuX92TzjmJk` | Bruce | M | 中年浑厚 | https://resource.heygen.ai/text_to_speech/locale=model=eleven_multilingual_v2id=2SdnapPUN7wvtCbkPSgdHV.mp3 |
|
|
409
|
+
| 4 | `6HiVdeiuBdZbtcnukrQn` | Luca | M | 年轻活力 | https://resource.heygen.ai/text_to_speech/locale=model=eleven_multilingual_v2id=FVKYscu8J8EVReBuZdPXnJ.mp3 |
|
|
410
|
+
|
|
411
|
+
> 维护说明:换 voice 时,从 `GET /v2/voices` 里筛 `preview_audio` 含 `multilingual` 的 EL 系列。其它非 EL 系列的"English"标签 voice **大概率**不能跨语言,要实测验证。
|
package/dist/commands/video.d.ts
CHANGED
|
@@ -1,3 +1,22 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
export declare function registerVideoCommand(program: Command): void;
|
|
3
|
+
export declare function hasFfmpegBinary(name: 'ffmpeg' | 'ffprobe'): boolean;
|
|
4
|
+
export declare function runFfmpegCopy(concatListPath: string, outputPath: string): {
|
|
5
|
+
ok: boolean;
|
|
6
|
+
stderr: string;
|
|
7
|
+
};
|
|
8
|
+
export declare function runFfmpegReencode(concatListPath: string, outputPath: string): {
|
|
9
|
+
ok: boolean;
|
|
10
|
+
stderr: string;
|
|
11
|
+
};
|
|
12
|
+
export declare function ffprobeDuration(filePath: string): number;
|
|
13
|
+
/**
|
|
14
|
+
* 3 层 failed_input 探测(SPEC 决策 #9):
|
|
15
|
+
* 1) ffmpeg stderr 含 input 路径字面 → 再 ffprobe verify 一次,验证 fail 才返回
|
|
16
|
+
* (P2-2 修正:ffmpeg `Input #N` 默认会把所有 input 名字打 stderr,
|
|
17
|
+
* Layer 1 命中不等于该 input 真坏;必须 ffprobe verify 避免 false positive)
|
|
18
|
+
* 2) 否则 ffprobe scan 全部 input,首个 exit≠0 = failed_input
|
|
19
|
+
* 3) 都 ffprobe OK 但 concat 仍失败 → null (codec mismatch,非单个 input 问题)
|
|
20
|
+
*/
|
|
21
|
+
export declare function detectFailedInput(stderr: string, inputs: string[]): string | null;
|
|
3
22
|
//# sourceMappingURL=video.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"video.d.ts","sourceRoot":"","sources":["../../src/commands/video.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAU,MAAM,WAAW,CAAC;
|
|
1
|
+
{"version":3,"file":"video.d.ts","sourceRoot":"","sources":["../../src/commands/video.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAU,MAAM,WAAW,CAAC;AAmB5C,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,OAAO,QA0QpD;AA6OD,wBAAgB,eAAe,CAAC,IAAI,EAAE,QAAQ,GAAG,SAAS,GAAG,OAAO,CAGnE;AAED,wBAAgB,aAAa,CAAC,cAAc,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG;IAAE,EAAE,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAOzG;AAED,wBAAgB,iBAAiB,CAAC,cAAc,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG;IAAE,EAAE,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAsB7G;AAED,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAYxD;AAED;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,MAAM,GAAG,IAAI,CAsBjF"}
|
package/dist/commands/video.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Option } from 'commander';
|
|
2
2
|
import * as path from 'path';
|
|
3
3
|
import * as fs from 'fs';
|
|
4
|
+
import { execFileSync, spawnSync } from 'child_process';
|
|
4
5
|
import ora from 'ora';
|
|
5
6
|
import { success, info } from '../utils/logger.js';
|
|
6
7
|
import { outputSuccess, outputError, isPrettyMode } from '../utils/output.js';
|
|
@@ -257,6 +258,7 @@ export function registerVideoCommand(program) {
|
|
|
257
258
|
relatedCommands: [
|
|
258
259
|
{ command: 'task get <id>', description: '查看任务状态' },
|
|
259
260
|
{ command: 'image <prompt>', description: '生成图像' },
|
|
261
|
+
{ command: 'video stitch', description: '拼接多段 mp4(ffmpeg concat)' },
|
|
260
262
|
],
|
|
261
263
|
notes: [
|
|
262
264
|
'默认 dashscope(仅图生视频,需要输入图像;自动配音默认开启)',
|
|
@@ -266,5 +268,277 @@ export function registerVideoCommand(program) {
|
|
|
266
268
|
'视频生成需要 1-5 分钟',
|
|
267
269
|
],
|
|
268
270
|
});
|
|
271
|
+
registerVideoStitch(cmd);
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* `gen video stitch` —— 多段 mp4 拼接 (ffmpeg concat) primitive.
|
|
275
|
+
*
|
|
276
|
+
* 实施 SPEC: docs/superpowers/specs/2026-05-16-gen-video-stitch-primitive.md (optima-agent)
|
|
277
|
+
*
|
|
278
|
+
* 关键设计:
|
|
279
|
+
* - Mode A (--workspace-dir): glob <dir>/segments/seg-*.mp4 按文件名 lexical sort
|
|
280
|
+
* - Mode B (--inputs): 显式 mp4 列表,用户给的顺序
|
|
281
|
+
* - --codec auto (默认): 试 -c copy → fail 回退 libx264+aac
|
|
282
|
+
* - 失败 failed_input 探测 3 层: stderr parse → ffprobe scan → null + codec mismatch
|
|
283
|
+
* - concat list 用固定路径 <workspace>/.stitch-concat.txt,不用 mktemp
|
|
284
|
+
*
|
|
285
|
+
* 返回 JSON (成功): { output_path, duration_sec, concat_mode, input_count }
|
|
286
|
+
* 返回 JSON (失败): { error: { code, message, failed_input } }
|
|
287
|
+
*/
|
|
288
|
+
function registerVideoStitch(parent) {
|
|
289
|
+
const cmd = parent
|
|
290
|
+
.command('stitch')
|
|
291
|
+
.description('拼接多段 mp4 为单个视频(ffmpeg concat)')
|
|
292
|
+
.option('--workspace-dir <dir>', 'Mode A: 扫描 <dir>/segments/seg-*.mp4 按文件名排序')
|
|
293
|
+
.option('--inputs <inputs...>', 'Mode B: 显式 mp4 列表 (顺序 = concat 顺序)')
|
|
294
|
+
// -o 短 flag 跟父命令 gen video -o <dir> 冲突 (commander 限制),subcommand 用长 flag --out
|
|
295
|
+
// SPEC §4.1 用 -o 是设计期写法,实施时改 --out 不影响契约 (wrapper 调时跟 SPEC 实施版本一致即可)
|
|
296
|
+
.requiredOption('--out <path>', '输出 mp4 路径')
|
|
297
|
+
.addOption(new Option('--codec <mode>', 'codec 模式: auto 试 copy 失败回退 reencode')
|
|
298
|
+
.choices(['auto', 'copy', 'reencode'])
|
|
299
|
+
.default('auto'))
|
|
300
|
+
.action(async (options) => {
|
|
301
|
+
const pretty = isPrettyMode(options);
|
|
302
|
+
try {
|
|
303
|
+
// P2-3: ffmpeg / ffprobe PATH 预检
|
|
304
|
+
if (!hasFfmpegBinary('ffmpeg')) {
|
|
305
|
+
outputError('STITCH_FFMPEG_NOT_FOUND', '未找到 ffmpeg 命令。请先安装 ffmpeg (https://ffmpeg.org/) 并加入 PATH', options);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (!hasFfmpegBinary('ffprobe')) {
|
|
309
|
+
outputError('STITCH_FFPROBE_NOT_FOUND', '未找到 ffprobe 命令 (ffmpeg 套件)。请确认 ffmpeg 完整安装并加入 PATH', options);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
// Mode A vs Mode B 互斥校验
|
|
313
|
+
const hasWorkspace = !!options.workspaceDir;
|
|
314
|
+
const hasInputs = Array.isArray(options.inputs) && options.inputs.length > 0;
|
|
315
|
+
if (hasWorkspace && hasInputs) {
|
|
316
|
+
outputError('STITCH_USAGE_ERROR', '--workspace-dir 和 --inputs 二选一,不能同时传', options);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if (!hasWorkspace && !hasInputs) {
|
|
320
|
+
outputError('STITCH_USAGE_ERROR', '必须传 --workspace-dir <dir> 或 --inputs <p1> <p2> ...', options);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
// --codec 校验由 commander .choices() 处理,这里直接读 (无效值 commander 已 reject)
|
|
324
|
+
const codec = options.codec;
|
|
325
|
+
// 解析 inputs (Mode A glob 或 Mode B 直接用)
|
|
326
|
+
let inputs;
|
|
327
|
+
if (hasWorkspace) {
|
|
328
|
+
const segmentsDir = path.join(path.resolve(options.workspaceDir), 'segments');
|
|
329
|
+
if (!fs.existsSync(segmentsDir)) {
|
|
330
|
+
outputError('STITCH_FAILED', `segments 目录不存在: ${segmentsDir}`, options);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
inputs = fs
|
|
334
|
+
.readdirSync(segmentsDir)
|
|
335
|
+
.filter((f) => /^seg-\d+\.mp4$/.test(f))
|
|
336
|
+
// natural sort: 不依赖填充宽度,seg-2 < seg-10 < seg-100 都对
|
|
337
|
+
// (lexical sort 在混 1/2/3 位时会错乱:seg-1 < seg-10 < seg-100 < seg-2)
|
|
338
|
+
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
|
|
339
|
+
.map((f) => path.join(segmentsDir, f));
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
inputs = options.inputs.map((p) => path.resolve(p));
|
|
343
|
+
}
|
|
344
|
+
if (inputs.length === 0) {
|
|
345
|
+
outputError('STITCH_FAILED', 'inputs 为空,没有 mp4 可拼接', options);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
// 验证 inputs 存在
|
|
349
|
+
for (const input of inputs) {
|
|
350
|
+
if (!fs.existsSync(input)) {
|
|
351
|
+
outputError('STITCH_FAILED', `input 文件不存在: ${input}`, options, { failed_input: input });
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
const outputPath = path.resolve(options.out);
|
|
356
|
+
// concat list 临时文件位置 (SPEC 决策 #11):
|
|
357
|
+
// - Mode A: <workspace>/.stitch-concat.txt
|
|
358
|
+
// - Mode B 无 workspace 概念,落 output dir 同卷,避免跨 fs 问题
|
|
359
|
+
// - 假设单作业,并发场景需各自 workspace 或换 mktemp (本 v1 不做)
|
|
360
|
+
const concatListPath = path.join(hasWorkspace ? path.resolve(options.workspaceDir) : path.dirname(outputPath), '.stitch-concat.txt');
|
|
361
|
+
const spinner = pretty ? ora(`拼接 ${inputs.length} 段 mp4...`).start() : null;
|
|
362
|
+
// 生成 concat list (ffmpeg concat demuxer 格式)
|
|
363
|
+
const concatContent = inputs.map((p) => `file '${p.replace(/'/g, "'\\''")}'`).join('\n') + '\n';
|
|
364
|
+
fs.writeFileSync(concatListPath, concatContent);
|
|
365
|
+
try {
|
|
366
|
+
// 执行 ffmpeg,根据 --codec 决定路径
|
|
367
|
+
let concatMode;
|
|
368
|
+
let ffmpegError = null;
|
|
369
|
+
if (codec === 'copy') {
|
|
370
|
+
// 只试 copy,失败直接报错
|
|
371
|
+
const r = runFfmpegCopy(concatListPath, outputPath);
|
|
372
|
+
if (r.ok) {
|
|
373
|
+
concatMode = 'copy';
|
|
374
|
+
}
|
|
375
|
+
else {
|
|
376
|
+
ffmpegError = r.stderr;
|
|
377
|
+
concatMode = 'copy'; // 标记,但下面会进 error path
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
else if (codec === 'reencode') {
|
|
381
|
+
// 直接 reencode,跳过 copy 试探
|
|
382
|
+
const r = runFfmpegReencode(concatListPath, outputPath);
|
|
383
|
+
if (r.ok) {
|
|
384
|
+
concatMode = 'reencode';
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
387
|
+
ffmpegError = r.stderr;
|
|
388
|
+
concatMode = 'reencode';
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
else {
|
|
392
|
+
// auto: 试 copy → fail 回退 reencode
|
|
393
|
+
const r1 = runFfmpegCopy(concatListPath, outputPath);
|
|
394
|
+
if (r1.ok) {
|
|
395
|
+
concatMode = 'copy';
|
|
396
|
+
}
|
|
397
|
+
else {
|
|
398
|
+
const r2 = runFfmpegReencode(concatListPath, outputPath);
|
|
399
|
+
if (r2.ok) {
|
|
400
|
+
concatMode = 'reencode';
|
|
401
|
+
}
|
|
402
|
+
else {
|
|
403
|
+
ffmpegError = r2.stderr;
|
|
404
|
+
concatMode = 'reencode';
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
if (ffmpegError) {
|
|
409
|
+
// 3 层 failed_input 探测
|
|
410
|
+
const failedInput = detectFailedInput(ffmpegError, inputs);
|
|
411
|
+
if (pretty)
|
|
412
|
+
spinner.fail('ffmpeg concat 失败');
|
|
413
|
+
outputError('STITCH_FAILED', failedInput
|
|
414
|
+
? `ffmpeg concat 失败 (failing input: ${failedInput})`
|
|
415
|
+
: 'ffmpeg concat 失败:所有 input 单独 ffprobe OK,但 concat 失败 (likely codec mismatch)', options, { failed_input: failedInput });
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
// 成功:ffprobe 取 duration
|
|
419
|
+
const duration = ffprobeDuration(outputPath);
|
|
420
|
+
if (pretty) {
|
|
421
|
+
spinner.succeed(`拼接完成 (${concatMode}): ${outputPath} [${duration.toFixed(2)}s, ${inputs.length} 段]`);
|
|
422
|
+
}
|
|
423
|
+
else {
|
|
424
|
+
outputSuccess({
|
|
425
|
+
output_path: outputPath,
|
|
426
|
+
duration_sec: duration,
|
|
427
|
+
concat_mode: concatMode,
|
|
428
|
+
input_count: inputs.length,
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
finally {
|
|
433
|
+
// 清理临时 concat list
|
|
434
|
+
if (fs.existsSync(concatListPath)) {
|
|
435
|
+
try {
|
|
436
|
+
fs.unlinkSync(concatListPath);
|
|
437
|
+
}
|
|
438
|
+
catch {
|
|
439
|
+
/* ignore */
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
catch (err) {
|
|
445
|
+
outputError('STITCH_FAILED', `拼接过程异常: ${err}`, options);
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
addEnhancedHelp(cmd, {
|
|
449
|
+
examples: [
|
|
450
|
+
'# Mode A: 自动扫描 workspace segments/',
|
|
451
|
+
'$ gen video stitch --workspace-dir ~/digital-human/videos/20260516-1430 --out final.mp4',
|
|
452
|
+
'# Mode B: 显式列 mp4 (任意顺序)',
|
|
453
|
+
'$ gen video stitch --inputs intro.mp4 seg-1.mp4 seg-2.mp4 outro.mp4 --out final.mp4',
|
|
454
|
+
'# 强制重编码 (保证 codec 兼容)',
|
|
455
|
+
'$ gen video stitch --workspace-dir . --out final.mp4 --codec reencode',
|
|
456
|
+
],
|
|
457
|
+
outputJson: `{
|
|
458
|
+
"success": true,
|
|
459
|
+
"data": {
|
|
460
|
+
"output_path": "/abs/path/final.mp4",
|
|
461
|
+
"duration_sec": 87.3,
|
|
462
|
+
"concat_mode": "copy",
|
|
463
|
+
"input_count": 4
|
|
464
|
+
}
|
|
465
|
+
}`,
|
|
466
|
+
relatedCommands: [
|
|
467
|
+
{ command: 'video', description: '生成单段视频' },
|
|
468
|
+
],
|
|
469
|
+
notes: [
|
|
470
|
+
'Mode A / Mode B 二选一,不能同时传',
|
|
471
|
+
'--codec auto (默认): 试 -c copy → fail 回退 libx264+aac,JSON 返回实际 concat_mode',
|
|
472
|
+
'失败时 failed_input 字段指出 broken mp4 (若可定位)',
|
|
473
|
+
'concat list 临时文件用完即删,无残留',
|
|
474
|
+
],
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
// ===== Helpers (exported for unit tests) =====
|
|
478
|
+
export function hasFfmpegBinary(name) {
|
|
479
|
+
const r = spawnSync(name, ['-version'], { encoding: 'utf-8' });
|
|
480
|
+
return r.status === 0;
|
|
481
|
+
}
|
|
482
|
+
export function runFfmpegCopy(concatListPath, outputPath) {
|
|
483
|
+
const r = spawnSync('ffmpeg', ['-y', '-f', 'concat', '-safe', '0', '-i', concatListPath, '-c', 'copy', outputPath], { encoding: 'utf-8' });
|
|
484
|
+
return { ok: r.status === 0, stderr: r.stderr || '' };
|
|
485
|
+
}
|
|
486
|
+
export function runFfmpegReencode(concatListPath, outputPath) {
|
|
487
|
+
const r = spawnSync('ffmpeg', [
|
|
488
|
+
'-y',
|
|
489
|
+
'-f',
|
|
490
|
+
'concat',
|
|
491
|
+
'-safe',
|
|
492
|
+
'0',
|
|
493
|
+
'-i',
|
|
494
|
+
concatListPath,
|
|
495
|
+
'-c:v',
|
|
496
|
+
'libx264',
|
|
497
|
+
'-preset',
|
|
498
|
+
'fast',
|
|
499
|
+
'-c:a',
|
|
500
|
+
'aac',
|
|
501
|
+
outputPath,
|
|
502
|
+
], { encoding: 'utf-8' });
|
|
503
|
+
return { ok: r.status === 0, stderr: r.stderr || '' };
|
|
504
|
+
}
|
|
505
|
+
export function ffprobeDuration(filePath) {
|
|
506
|
+
try {
|
|
507
|
+
const out = execFileSync('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', filePath], { encoding: 'utf-8' });
|
|
508
|
+
const d = parseFloat(out.trim());
|
|
509
|
+
return Number.isFinite(d) ? d : 0;
|
|
510
|
+
}
|
|
511
|
+
catch {
|
|
512
|
+
return 0;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* 3 层 failed_input 探测(SPEC 决策 #9):
|
|
517
|
+
* 1) ffmpeg stderr 含 input 路径字面 → 再 ffprobe verify 一次,验证 fail 才返回
|
|
518
|
+
* (P2-2 修正:ffmpeg `Input #N` 默认会把所有 input 名字打 stderr,
|
|
519
|
+
* Layer 1 命中不等于该 input 真坏;必须 ffprobe verify 避免 false positive)
|
|
520
|
+
* 2) 否则 ffprobe scan 全部 input,首个 exit≠0 = failed_input
|
|
521
|
+
* 3) 都 ffprobe OK 但 concat 仍失败 → null (codec mismatch,非单个 input 问题)
|
|
522
|
+
*/
|
|
523
|
+
export function detectFailedInput(stderr, inputs) {
|
|
524
|
+
// Layer 1: stderr 含具体 input 路径 + ffprobe 验证
|
|
525
|
+
for (const input of inputs) {
|
|
526
|
+
if (stderr.includes(input) || stderr.includes(path.basename(input))) {
|
|
527
|
+
const r = spawnSync('ffprobe', ['-v', 'error', input], { encoding: 'utf-8' });
|
|
528
|
+
if (r.status !== 0 || (r.stderr && r.stderr.trim() !== '')) {
|
|
529
|
+
return input; // 验证确认坏
|
|
530
|
+
}
|
|
531
|
+
// stderr 提了 input 但 ffprobe OK → 可能只是 Input #N 标识,降级 Layer 2
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
// Layer 2: ffprobe scan 每个 input
|
|
535
|
+
for (const input of inputs) {
|
|
536
|
+
const r = spawnSync('ffprobe', ['-v', 'error', input], { encoding: 'utf-8' });
|
|
537
|
+
if (r.status !== 0 || (r.stderr && r.stderr.trim() !== '')) {
|
|
538
|
+
return input;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
// Layer 3: 都 OK 但 concat fail → 不是单个 input 问题
|
|
542
|
+
return null;
|
|
269
543
|
}
|
|
270
544
|
//# sourceMappingURL=video.js.map
|