---
name: video-translate
description: "把单段口播视频翻译成 English / Thai (Thailand) / Malay (Malaysia) / Vietnamese (Vietnam) 之一,出片自带译音(自动克隆原说话人)+ 烧录目标语花体字幕 + 默认 BGM ducking。触发场景:用户说翻译这个视频/做泰语版/英文版/越南语版/马来语版/视频本地化/换语言/dub。"
version: 1.0.3
owner_repo: Optima-Chat/optima-gen
---

# video-translate

把口播视频本地化:MiniMax 预置明亮女声译音 + 烧录目标语字幕 + BGM ducking(默认开)。**3 步主流程**(+ Step 0 预检 + Step 4 可选清理)。

## 适用语言

- `English`
- `Thai (Thailand)`
- `Malay (Malaysia)`
- `Vietnamese (Vietnam)`

其他语言用户说出来,**先 echo 警告**:"本 skill v1 只支持 4 语,其他语种支持中,可改用 `gen video-translate --lang <X>` 拿基础翻译(无花体字幕)"

## 前提

- 源视频 URL 公网可访问(或先让用户上传到 Optima → 拿 URL)
- 容器有:`gen` CLI(`@optima-chat/optima-gen` ≥ latest)、`video-translate` CLI(`@optima-chat/video-translate-tools` ≥ 1.0.9,`--style` 需 1.0.9)、`ffmpeg`、`curl`、`jq`
- 后端 2026-05 从 HeyGen 切换为 MiniMax pipeline(同接口,~100× 便宜)。CLI 接口未变,旧 `--mode` / `--dynamic-duration` flag 已无效会被忽略

## 输入

| 参数 | 必填 | 说明 |
|---|---|---|
| `URL` | ✅ | 公网可访问的源视频 URL |
| `LANG` | ✅ | 人类可读语言名(见上面 4 语) |
| `TAG` | ✅ | 对应的两字母 tag(`en` / `th` / `ms` / `vi`) |
| `BGM` | ⬜ | 自定义 BGM 文件路径(覆盖默认)。不传则用 npm 包内置 `bgm/default.mp3`(22s clean instrumental,自动 loop)|
| `NO_BGM` | ⬜ | 设非空值则跳过 BGM(出片只有人声 + 原视频 BGM 残留)|
| `VOICE` | ⬜ | MiniMax voice_id(从下面 ## Voice Catalog 选)。**不传 = `Portuguese_FriendlyNeighbor`** 默认(友好邻居女声,广谱适用——卖货 / 教程 / demo 都不违和)|
| `NAME` | ⬜ | 工作区名,默认从 URL 末段推 |
| `STYLE` | ⬜ | 字幕风格,**不传 = `classic`(原款)**。可选 `pop-soft` / `pop-3d` / `pop-hl` / `anton` / `luckyguy`,见 Step 0.6。需 `video-translate-tools` ≥ 1.0.9 |

## 3 步主流程(+ Step 0 预检 + Step 4 可选清理)

### Step 0:声明 + 工作区 + URL 预处理 + 源视频音量预检

```bash
## ⚠ URL 必须是公网 https URL,不能是本地路径 /home/aiuser/...
## gen video-translate 只接受 https URL(后端要下载源视频)
## 如果是本地路径,先用 chat 系统的 file API 签 URL
if [[ ! "$URL" =~ ^https?:// ]]; then
  echo "INFO: 本地路径 '$URL',需要上传拿 https URL"
  TOKEN=$(jq -r '.access_token' ~/.optima/token.json)
  ## shell.optima.onl 的 file signing endpoint(具体路径以系统实际为准,
  ## 这里给出常见模式;如果不行,根据 stderr 报错调整)
  SIGNED=$(curl -sS -X POST "https://shell.optima.onl/api/files/sign" \
    -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
    -d "{\"path\":\"$URL\"}" 2>&1 | jq -r '.data.url // .url // empty' 2>/dev/null)
  if [ -z "$SIGNED" ]; then
    echo "ERR: 拿不到 signed URL,把 '$URL' 上传到 chat 系统拿 https URL 再来"
    exit 1
  fi
  URL="$SIGNED"
  echo "URL → $URL"
fi

## URL 末段可能含 query string(S3 预签名 url 含 token)— 先去掉再 basename
NAME="${NAME:-$(echo "$URL" | cut -d'?' -f1 | xargs basename | sed 's/\.[^.]*$//' | sed 's/[^A-Za-z0-9_-]/_/g')}"
WORK="./videos/${NAME}.work"
mkdir -p "$WORK"

## 音量预检 — Whisper ASR 在 < -25dB 会返回 0 segments(adapter 抛 "source audio too quiet (<-25dB)" 错误)
## ffmpeg 输出在 stderr,必须 2>&1
ffmpeg -i "$URL" -af volumedetect -f null - 2>&1 | grep mean_volume
```

如果 `mean_volume < -25dB`,提示用户:"源视频音量太低(< −25dB),翻译服务大概率会报无人声。建议先用 `ffmpeg -i in.mp4 -af 'volume=20dB,acompressor=threshold=-20dB:ratio=4' -c:v copy out.mp4` 放大后再翻译。"

### Step 0.5:音色 — 默认**自动双音色**(LLM 标注 A/B),**不问用户**

后端默认行为(2026-05 起):**自动双音色** — 翻译时 LLM 给每个 cue 标 speaker A 或 B:
- **A = `Russian_CrazyQueen`**:反应方 / 提问 / 惊呼("等等!"/ "多少钱?"/ "天哪")
- **B = `Portuguese_FriendlyNeighbor`**:卖家 / 介绍 / 推销("正品"/ "玻璃材质"/ "今天下单送")

对话型视频(赵大大式带货)→ **双女声自然对话感**。单口播视频 → LLM 全标 A,自动退回单音色,**不会瞎切**。**SKILL 不询问音色**,直接 backend 自动判断。

```bash
## 默认走 backend 自动双音色,不需要在 Step 1 显式传 --voice
VOICE=""
```

#### 仅当用户**主动要求换音色**时,才列下面备选并等回复:

**用户想全用单一音色**(关掉双音色,例如"全用一个声音"/ "我不要双音色"):

```bash
SINGLE_VOICE_FLAG="--single-voice"   ## Step 1 传给 gen video-translate
```

**用户想指定具体音色**(例如"用男声"/ "换个更可爱的"):

```
你可以从这 6 个预置音色挑(传 --voice 后所有 cue 都用该 voice,关闭双音色):
  1. Friendly     - 友好邻居广谱女声(F)         — 默认 B (卖家)
  2. CrazyQueen   - 充满活力 + 狂野(F)         — 默认 A (反应方)
  3. Sweet        - 甜美年轻(F)                — 美妆/母婴/温和卖货
  4. Lovely       - 可爱俏皮(F)                — Z 世代/活泼带货
  5. Trustworthy  - 美式磁性沉稳男声(M)        — 科技/汽车/正经 demo
  6. Aussie       - 澳式阳光男声(M)            — 短视频/运动/快消

回复数字或名字。
```

| 用户回复 | VOICE 变量值 |
|---|---|
| `1` / `Friendly` | **`VOICE=Portuguese_FriendlyNeighbor`** |
| `2` / `CrazyQueen` / `狂野` | **`VOICE=Russian_CrazyQueen`** |
| `3` / `Sweet` / `甜美` | **`VOICE=Sweet_Girl`** |
| `4` / `Lovely` / `可爱` | **`VOICE=lovely_girl`** |
| `5` / `Trustworthy` / `磁性` / `沉稳` | **`VOICE=English_Trustworthy_Man`** |
| `6` / `Aussie` / `澳` / `阳光` | **`VOICE=English_Aussie_Bloke`** |

#### 严禁

- ❌ **主动问"你要哪个音色"** — 默认双音色够好,问了反而增加摩擦
- ❌ **用 AskUserQuestion 工具**(只有用户主动要求换才列文字菜单)
- ❌ **加 "克隆原声" 选项** — MiniMax 预置音色管线 v1 不做 voice clone

### Step 0.6:字幕风格 — 默认 `classic`(原款),**不主动问**

`render-ass` 支持 `--style`(需 `video-translate-tools` ≥ 1.0.9)。**默认不传 = `classic`,与原来的花体字幕完全一致**,默认流程零变化。

```bash
## 默认走原款,不显式传 --style
STYLE=""
```

#### 仅当用户**主动要求换字幕风格**时(如"字幕换个风格"/"字幕太单调"/"用 anton"),才列菜单并等回复:

```
字幕风格(回复数字或名字):
  1. classic   - 原款:白字黑边 + 粉色关键词(默认)
  2. pop-soft  - 立体软影:原款 + 柔和投影,更立体
  3. pop-3d    - 立体彩影:洋红 3D 硬投影 + 亮黄关键词,最潮
  4. pop-hl    - 关键词亮填:原款 + 关键词亮黄实填
  5. anton     - 条形:高条形粗体,现代利落
  6. luckyguy  - 圆润:圆润漫画体,活泼
```

| 用户回复 | STYLE |
|---|---|
| `1` / classic / 原款 | `STYLE=""`(=classic) |
| `2` / pop-soft / 立体软影 | `STYLE=pop-soft` |
| `3` / pop-3d / 立体彩影 / 潮 | `STYLE=pop-3d` |
| `4` / pop-hl / 关键词亮填 | `STYLE=pop-hl` |
| `5` / anton / 条形 | `STYLE=anton` |
| `6` / luckyguy / 圆润 | `STYLE=luckyguy` |

#### 严禁

- ❌ **主动问"你要哪个字幕风格"** — 默认 `classic` 够好,问了增加摩擦(同音色逻辑)
- ❌ **用 AskUserQuestion 工具**(只有用户主动要求换才列文字菜单)
- ❌ 把菜单外的名字直传 `--style` — render-ass 会 warn 回退 classic,应先在表里映射成合法值

> 风格只改配色/描边/阴影/关键词,**字体仍按语言兜底**(th=Sarabun、vi=Noto;anton 例外,越南语用 Anton)。所有风格对 4 语都安全,不会豆腐块。

### Step 1:翻译(用现成 CLI)

```bash
RAW_DIR="$WORK/raw"
mkdir -p "$RAW_DIR"
gen video-translate \
  --video-url "$URL" \
  --lang "$LANG" \
  ${VOICE:+--voice "$VOICE"} \
  ${SINGLE_VOICE_FLAG:-} \
  -o "$RAW_DIR" \
  > "$WORK/gen.json"
```

`gen video-translate` 自动轮询。**完成时间随 cue 数线性增长**(adapter 段间 1.5s sleep 防 RPM):
- < 30s 视频(~10 cues):2-3 min
- 1 min 视频(~20 cues):3-5 min
- 5 min 视频(~50 cues):**10-15 min**(注意 BullMQ worker slot 占用)
- 10 min+ 视频:**不推荐**,接近 30 min poll timeout 上限

失败 / 超时见末尾错误表。

幂等:`$WORK/gen.json` 存在则跳。

**⚠ 提取字段必须用 `.data.` 前缀**:gen-cli 的 `outputSuccess` 把所有字段包装在 `{success: true, data: {...}}` 结构里。**不带 `.data.` 永远读不到字段,SKILL 会卡死**:

```bash
## gen-cli 输出格式: {"success": true, "data": {"task_id": "...", "audio_url": "...", "caption_url": "..."}}
AUDIO_URL=$(jq -r '.data.audio_url // empty' "$WORK/gen.json")
CAP_URL=$(jq -r '.data.caption_url // empty' "$WORK/gen.json")
[ -n "$AUDIO_URL" ] && [ -n "$CAP_URL" ] || {
  echo "ERR: gen.json 缺 .data.audio_url 或 .data.caption_url 字段。原始 response:"
  cat "$WORK/gen.json"
  exit 1
}
```

### Step 2:下载译音 + SRT,渲染 ASS

```bash
AUDIO="$WORK/translated_audio.wav"
[ -s "$AUDIO" ] || curl -sSL --retry 1 "$AUDIO_URL" -o "$AUDIO"

SRT="$WORK/caption.srt"
[ -s "$SRT" ] || curl -sSL --retry 1 "$CAP_URL" -o "$SRT"

## SRT 必须非空 — 后端偶发返回 0 字节 SRT(Whisper 无语音 / 上传失败),后续 sync 检查会拿到空 end_time
[ -s "$SRT" ] || { echo "ERR: $SRT 空或损坏,gen.json 内 caption_url 可能已 expire,重跑 Step 1"; exit 1; }

ASS="$WORK/subs.ass"
TRANS="$WORK/translations.json"
video-translate render-ass \
  --srt "$SRT" \
  --lang "$TAG" \
  ${STYLE:+--style "$STYLE"} \
  --translations "$TRANS" \
  --out "$ASS"
```

用户可手编 `$TRANS`(给关键词加 `**word**` 触发粉色 KW 描边)后删 `$ASS` 重跑这步。

### Step 2.5:A/V sync 预检(必跑,挡掉调试不收敛的常见根因)

**为什么必跑**:用户反馈"字幕和配音调试多次不同频"。绝大多数情况不是字幕样式问题,而是后端 audio 时长跟 SRT 末尾时间戳本身就对不上(MiniMax adaptive-speed 调整不完美时会有 < 1s 累积漂移),或者跟原视频时长差距过大。**不在 mux 前查,用户只能盲调,改 10 次还是错。**

```bash
## 原视频本地化(Step 3 也需要,提前到这里让 sync 预检能算 VIDEO_DUR)
ORIG_VIDEO="$URL"
if [[ "$URL" =~ ^https?:// ]]; then
  ORIG_VIDEO="$WORK/orig.mp4"
  [ -f "$ORIG_VIDEO" ] || curl -sSL "$URL" -o "$ORIG_VIDEO"
fi

## audio 实际时长(秒,保留 3 位小数)
AUDIO_DUR=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$AUDIO")

## SRT 最后一行 cue 的 end 时间(秒)
SRT_END=$(awk '
  /-->/ { t=$3; gsub(",", ".", t); split(t, p, ":"); end = p[1]*3600 + p[2]*60 + p[3] }
  END   { print end }
' "$SRT")

## 原视频时长(用于检测 audio 超出/不足)
VIDEO_DUR=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$ORIG_VIDEO" 2>/dev/null || echo "0")

printf "AV-sync: audio=%.3fs srt_end=%.3fs video=%.3fs\n" "$AUDIO_DUR" "$SRT_END" "$VIDEO_DUR"

## 1. audio vs SRT — 这是字幕看起来 "晚" 或 "早" 收尾的直接原因
DELTA_AS=$(awk -v a="$AUDIO_DUR" -v s="$SRT_END" 'BEGIN { d=a-s; if (d<0) d=-d; print d }')
if awk "BEGIN { exit !($DELTA_AS > 0.5) }"; then
  echo "ERR: audio 与 SRT 末时间差 ${DELTA_AS}s (>0.5s)。"
  echo "     根因多半是后端本次输出 audio/SRT 不同步,继续 mux 字幕会全段漂。"
  echo "     remediation: rm $WORK/gen.json $AUDIO $SRT && 重跑 Step 1 拿新一对 audio+SRT。"
  exit 1
fi

## 2. audio vs video — 字幕和声音一致但跟画面不齐
if [ "$VIDEO_DUR" != "0" ]; then
  DELTA_AV=$(awk -v a="$AUDIO_DUR" -v v="$VIDEO_DUR" 'BEGIN { d=a-v; if (d<0) d=-d; print d }')
  if awk "BEGIN { exit !($DELTA_AV > 1.0) }"; then
    echo "INFO: audio 与原视频时长差 ${DELTA_AV}s。mux 会按 --raw 时长裁/补,字幕跟音同步但画面可能错位。"
  fi
fi
```

**触发条件 + 决策**:
- `DELTA_AS > 0.5s` → **默认停**,提示用户重跑 Step 1。盲跑 mux 然后再调字幕样式没用,根因在后端 TTS 输出。
- `DELTA_AV > 1.0s` → 只是 info,不挡;但要在 Step 3 出片汇报里如实告诉用户。
- 两条都 ≤ 阈值 → 直通 Step 3。

幂等:这个 block 没有副作用,可以反复跑。

### Step 3:Mux 出片

```bash
FINAL="./videos/${NAME}_${TAG}.mp4"

## --orig-video 关键:用用户原视频画面(MiniMax pipeline 不生成新视频,只换音轨,这里把原画面+新音轨合一)
## ORIG_VIDEO 通常已在 Step 2.5 落到本地;这里幂等兜底,允许只想换字幕样式时跳着复跑 Step 3
ORIG_VIDEO="${ORIG_VIDEO:-$URL}"
if [[ "$ORIG_VIDEO" =~ ^https?:// ]]; then
  ORIG_VIDEO="$WORK/orig.mp4"
  [ -f "$ORIG_VIDEO" ] || curl -sSL "$URL" -o "$ORIG_VIDEO"
fi

video-translate mux \
  --raw "$AUDIO" \
  --orig-video "$ORIG_VIDEO" \
  --ass "$ASS" \
  ${BGM:+--bgm "$BGM"} \
  ${NO_BGM:+--no-bgm} \
  --work "$WORK" \
  --out "$FINAL"
```

**关键**:管线匹配本地水杯/zhaodada 批量出片行为:
- `--orig-video` = 用户原视频画面(MiniMax 不动画面,只换音轨)
- `--raw` = 后端译音(audio_url 下载的 wav)
- 字幕烧 + BGM ducking 走 mux 内置

**BGM + 花体字幕样式 = 默认产出的一部分,不是可选项。** 不要问用户"要不要加",不要在结尾说"如需 BGM/字幕样式可补充"。`video-translate mux` 默认就用 `bgm/default.mp3` + Path-B 描边样式;`render-ass --lang $TAG` 默认按语言选字体(Bangers / Sarabun / Noto Sans)。不要绕过 `mux` 自己手写 ffmpeg(会丢 BGM + 字幕样式 + 音轨规范化)。

如果 `mux` 真的失败,先把完整 stderr 给用户看(不要默默用 `ffmpeg -i ... -c copy` 兜底出"裸版"),让用户决定下一步。

## 出片汇报口径

只描述事实("xxx_th.mp4 翻译完成,泰语花体字幕 + BGM ducking 已烧录")。**禁止**任何形式的"如需 X 可以告诉我"尾巴 — BGM 和字幕样式不是可选 add-on,这种话术暗示没做。

### Step 4(可选):清理

```bash
## 出片成功且用户确认满意后,删中间产物释放空间
rm -rf "$WORK"
```

---

## 批量模式(多语种,一段视频翻多语)

**触发条件**:用户一次请求 ≥2 种目标语言("翻译成英/泰/越/马 4 国语")。

**不要按单语流程循环跑 N 次**——会有两个致命问题:
1. wall time = N × 单次 (~5min/lang × 4 = 20min)
2. chat bash 队列被 N 条阻塞命令塞满,后续命令(mux/SFX)全卡"准备中"

改用 **单条 bash 内 N 路 subshell 并发**:chat 队列只占 1 slot。

> ⚠ **MiniMax RPM 限速注意**:MiniMax 新账户 RPM 默认 1-2,4 路并发会触发后端段间 retry,实际 wall time 可能并不省 vs 串行。如果发现 batch 比单语 ×4 还慢,考虑升级 MiniMax tier 或改回串行。Phase 1 默认仍走并发(代码路径已验证)。

### Step B0:URL 预处理 + workspace + 音量预检 + 源视频下载(只跑一次)

```bash
## ⚠ URL 预处理:同单语 Step 0,本地路径必须先上传拿 https URL
if [[ ! "$URL" =~ ^https?:// ]]; then
  TOKEN=$(jq -r '.access_token' ~/.optima/token.json)
  SIGNED=$(curl -sS -X POST "https://shell.optima.onl/api/files/sign" \
    -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
    -d "{\"path\":\"$URL\"}" 2>&1 | jq -r '.data.url // .url // empty' 2>/dev/null)
  if [ -z "$SIGNED" ]; then
    echo "ERR: 拿不到 signed URL for '$URL'"; exit 1
  fi
  URL="$SIGNED"
fi

NAME="${NAME:-$(echo "$URL" | cut -d'?' -f1 | xargs basename | sed 's/\.[^.]*$//' | sed 's/[^A-Za-z0-9_-]/_/g')}"
mkdir -p "./videos/${NAME}.batch"

## 音量预检(同单语 Step 0)
ffmpeg -i "$URL" -af volumedetect -f null - 2>&1 | grep mean_volume

## 源视频下载一次,所有 mux 共用(避免 N 次重复下载)
ORIG_VIDEO="./videos/${NAME}.batch/orig.mp4"
[ -f "$ORIG_VIDEO" ] || curl -sSL "$URL" -o "$ORIG_VIDEO"
```

### Step B0.5:voice 默认 backend 预置,不问

同单语 Step 0.5,默认 `VOICE=""` 走后端 `Portuguese_FriendlyNeighbor`。仅当用户主动要求换音色才列菜单 + 设 `VOICE`。所有语种 subshell 共用同一个 `VOICE`。

字幕风格同理(Step 0.6):默认 `STYLE=""`(=classic 原款),仅用户主动要求才设;所有语种共用同一个 `STYLE`,已在下方 per-lang 模板的 `render-ass` 里以 `${STYLE:+--style "$STYLE"}` 透传。

### Step B1:并发派出所有翻译(后台跑,~5 秒返回)

```bash
## 用户实际要的语种,从下面 4 个里选(不要的注释掉)
LANG_LIST=(
  "English:en"
  "Thai (Thailand):th"
  "Malay (Malaysia):ms"
  "Vietnamese (Vietnam):vi"
)

for entry in "${LANG_LIST[@]}"; do
  LANG="${entry%:*}"
  TAG="${entry##*:}"
  WORK="./videos/${NAME}_${TAG}.work"
  mkdir -p "$WORK/raw"

  ## 后台启动 gen video-translate,各自写 gen.json,bash 立即继续
  nohup gen video-translate \
    --video-url "$URL" --lang "$LANG" \
    ${VOICE:+--voice "$VOICE"} \
    -o "$WORK/raw" > "$WORK/gen.json" 2>&1 &

  echo "$TAG: launched (PID $!) -> $WORK/gen.json"
done
echo "=== all submitted, returning immediately ==="
```

这一步 5 秒返回。**关键**:用 `nohup ... &` 后台启动,bash exit 后进程继续(尽量 survive 短暂 idle / 容器升级)。每个进程会在 MiniMax pipeline 完成后把 audio_url/caption_url 写入对应的 gen.json。

### Step B2-B5:逐语种等结果 → 出片(每个一条独立 bash)

**重点:每个语种用单独的 bash 命令处理,不要再用 `&` 并发**。这样:
- chat 看到每个语种独立完成、独立报告
- 第 1 个 bash 等翻译(~5-8 min),后续每个 bash 几乎瞬间完成(因为 4 个翻译是并行跑的,后续几个早已 done)
- 失败隔离:1 个失败不影响其他

通用 per-lang 模板(把 `$TAG` 换成 en / th / ms / vi 各跑一遍):

```bash
TAG=en                                   ## ← 换语种就改这个
WORK="./videos/${NAME}_${TAG}.work"
FINAL="./videos/${NAME}_${TAG}.mp4"

## 等 gen.json 写完(或失败)
## ⚠ gen-cli 用 outputSuccess 包装在 {success, data: {...}} 里,字段在 .data.* 下
DEADLINE=$(( $(date +%s) + 1800 ))     ## 30min 上限,跟后端 poll timeout 对齐
while true; do
  ## Case 1: 成功 → .data.audio_url 非 null
  if jq -e '.data.audio_url' "$WORK/gen.json" >/dev/null 2>&1; then
    break
  fi
  ## Case 2: 业务失败 → .data.error_message(outputSuccess with status=failed)
  if jq -e '.data.error_message' "$WORK/gen.json" >/dev/null 2>&1; then
    echo "[$TAG] FAIL: $(jq -r '.data.error_message' "$WORK/gen.json")"
    exit 1
  fi
  ## Case 3: CLI 异常 → .error.code/.error.message(outputError 抛出)
  if jq -e '.error' "$WORK/gen.json" >/dev/null 2>&1; then
    echo "[$TAG] FAIL: $(jq -r '.error.code + \": \" + .error.message' "$WORK/gen.json")"
    exit 1
  fi
  ## Case 4: 30min 超时(MiniMax pipeline 一般 3-8min,30min 还没就是真挂了)
  if [ "$(date +%s)" -gt "$DEADLINE" ]; then
    echo "[$TAG] TIMEOUT 30min,gen.json 内容:"; cat "$WORK/gen.json"; exit 1
  fi
  sleep 15
done

## 拿到 audio_url + caption_url(必须带 .data. 前缀)
AUDIO_URL=$(jq -r '.data.audio_url' "$WORK/gen.json")
CAP_URL=$(jq -r '.data.caption_url' "$WORK/gen.json")

## 下载 + render-ass
curl -sSL --retry 1 "$AUDIO_URL" -o "$WORK/translated_audio.wav"
curl -sSL --retry 1 "$CAP_URL"   -o "$WORK/caption.srt"
[ -s "$WORK/caption.srt" ] || { echo "[$TAG] ERR: SRT 空/损坏"; exit 1; }
video-translate render-ass \
  --srt "$WORK/caption.srt" --lang "$TAG" \
  ${STYLE:+--style "$STYLE"} \
  --translations "$WORK/translations.json" \
  --out "$WORK/subs.ass"

## A/V sync 预检(同单语 Step 2.5,挡后端偶发 audio/SRT 不同步)
AUDIO_DUR=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$WORK/translated_audio.wav")
SRT_END=$(awk '
  /-->/ { t=$3; gsub(",", ".", t); split(t, p, ":"); end = p[1]*3600 + p[2]*60 + p[3] }
  END   { print end }
' "$WORK/caption.srt")
DELTA_AS=$(awk -v a="$AUDIO_DUR" -v s="$SRT_END" 'BEGIN { d=a-s; if (d<0) d=-d; print d }')
printf "[%s] AV-sync: audio=%.3fs srt_end=%.3fs delta=%.3fs\n" "$TAG" "$AUDIO_DUR" "$SRT_END" "$DELTA_AS"
if awk "BEGIN { exit !($DELTA_AS > 0.5) }"; then
  echo "[$TAG] WARN: audio/SRT 时间差 >0.5s,跳过本语 mux。删 $WORK/gen.json 重跑 Step B1 该 lang。"
  exit 1
fi

## mux 出片
video-translate mux \
  --raw "$WORK/translated_audio.wav" \
  --orig-video "$ORIG_VIDEO" \
  --ass "$WORK/subs.ass" \
  ${BGM:+--bgm "$BGM"} ${NO_BGM:+--no-bgm} \
  --work "$WORK" \
  --out "$FINAL"

echo "[$TAG] DONE -> $FINAL"
```

**Agent 执行节奏**:
1. 跑 Step B1(1 条 bash,5 秒返回,**立刻告知用户"4 个翻译派出去了"**)
2. 跑 per-lang 模板 `TAG=en`(1 条 bash,~12 min,出片后**立刻告知用户"English 出片 ✓"**)
3. 跑 per-lang 模板 `TAG=th`(1 条 bash,~30 秒,出片后**立刻告知用户"Thai 出片 ✓"**)
4. 跑 per-lang 模板 `TAG=ms`(~30 秒)
5. 跑 per-lang 模板 `TAG=vi`(~30 秒)

**总 wall time 不变(~13 min)**,但用户每 30 秒看到一个出片,不是 12 min 黑盒后突然 4 个一起出。

输出:`./videos/${NAME}_en.mp4` / `_th.mp4` / `_ms.mp4` / `_vi.mp4`

### 单语 vs 渐进批量对比

| | 单语循环(❌ 不要) | 渐进批量(✓) |
|---|---|---|
| 源视频下载 | N 次重下 | 1 次共用 |
| 翻译调用 | 串行 N × 5min | 并发(受 MiniMax RPM 限速影响) |
| chat 队列占 | N 条阻塞 bash | 1 条 submit + N 条快速 poll |
| 失败隔离 | 一个挂全停 | per-lang subshell + bash 独立 |
| 用户感知 | 黑盒 ~20 min | 第 1 个 ~5-8 min 出,后续每 ~30 秒 1 个 |
| **wall time(N=4)** | **~20 min** | **~8-12 min**(实际看 MiniMax tier) |

### 失败处理

- 单 lang 失败:per-lang bash `exit 1`,agent 报告该语种失败但**继续跑下一个语种的 bash**
- 用户对失败的单 lang re-run → 删 `$WORK/gen.json` 重跑 Step B1 单语 + per-lang 模板即可
- MiniMax 余额不足 / RPM 限速持续失败 → 当 lang 个失败处理,不阻塞其他 lang

## 错误处理

| 失败 | 处理 |
|---|---|
| URL 不通 | 重传或上传到 Optima 拿新 URL,不消耗翻译服务 credits |
| 源视频音量过低 | 让用户先用 ffmpeg `volume=20dB,acompressor` 放大后重传 |
| 后端 `status: failed` 包含 "no speaker" / "0 segments" | 源视频音量过低,同上处理 |
| MiniMax `1008 insufficient balance` | MiniMax 账户余额不足,运维充值后重跑 |
| MiniMax `1002 rate limit exceeded` 持续失败 | RPM tier 不够;后端有指数退避兜底,长视频可能超时 → 联系运维升级 tier |
| 后端其他失败 | 透出 task_id,提示用 `gen task get <id>` 查最新;干净退出 |
| 翻译 >30min 超时 | 同上,可能任务还在 running |
| `gen.json` 缺 `.audio_url` / `.caption_url` | gen 后端契约可能改字段名。打 `cat $WORK/gen.json` 看实际字段 |
| `curl <caption_url>` 失败 | S3 presigned URL 24 小时 expire。retry 1 次后仍失败 → 重跑 step 1 |
| `video-translate render-ass` SRT 解析失败 | 显示 SRT 头 20 行,不重试 |
| `video-translate mux` 字体 ☐ | 检查 `fc-match Bangers / Sarabun / "Noto Sans"` 是否精确返回 |
| Step 2.5 报 "audio 与 SRT 末时间差 > 0.5s" | MiniMax adaptive-speed 本次大幅偏离,**字幕调样式没用**。删 `$WORK/gen.json $AUDIO $SRT`,重跑 Step 1 |

## 不做

- ❌ 性别自动检测(默认 `Portuguese_FriendlyNeighbor` 女声,用户想换主动说)
- ❌ 硬字幕(burnt-in)抹除 — 源视频有的话出片会双语
- ❌ 自动加粗关键词 — SRT 直入无粉色,用户手编 translations.json
- ❌ 双说话人差异化音色 — MiniMax v1 单音色 default,手动传 speakers JSON 才能分轨(SKILL v1 不暴露)
- ❌ 克隆原说话人音色 — MiniMax voice clone API 是独立流程,v1 不上
- ❌ 一次多语言 — 一次一种语言

## 参考

- 设计 SPEC:https://github.com/Optima-Chat/video-translate/blob/main/SPEC.md (v3.1)
- 实测踩坑(gotchas):同上 §Gotchas

---

## Voice Catalog

下面 6 个 voice 是 MiniMax 预置音色,**任选一个,所有支持语言(en/th/ms/vi)都能说**(底层 `speech-02-turbo` + `language_boost` 跨语自适应)。POC 验证(2026-05)在卖货场景下跨 4 语均自然,Whisper 反向验证语种识别 > 90%。

| # | voice_id | 风格定位 | 适用场景 |
|---|---|---|---|
| 1 | `Portuguese_FriendlyNeighbor` | 充满活力的友好邻居(F) | **默认** — 销售 / 教程 / demo,广谱适用 |
| 2 | `Russian_CrazyQueen` | 充满活力 + 狂野不可预测(F) | 反应方 / 惊呼 / 高能开场 |
| 3 | `Sweet_Girl` | 甜美年轻(F) | 美妆 / 母婴 / 温和卖货 |
| 4 | `lovely_girl` | 可爱俏皮(F) | Z 世代 / 活泼带货 |
| 5 | `English_Trustworthy_Man` | 美式磁性沉稳(M,带通用美式口音) | 科技 / 汽车 / 严肃产品 demo |
| 6 | `English_Aussie_Bloke` | 阳光开朗(M,澳式口音) | 短视频 / 运动 / 快消 |

> 维护说明:从 MiniMax `POST /v1/get_voice` 接口可拉全量 303 个预置音色。挑跨语 voice 优先 `English_` / `Russian_` / `Portuguese_` / `Sweet_` / `lovely_` / `Indonesian_` 前缀(描述含 "活力 / 甜美 / 俏皮 / Trustworthy / Bloke" 关键词的);其他 `Chinese_` / `Korean_` / `Japanese_` 前缀的强单语音色硬说外语会有明显口音。试新 voice 必须先用 `text="测试一句"` × 4 语单独调一次听感再批量上(参考 [[feedback_api_accept_neq_use]]:API 接受 ≠ 输出可用)。
