# Generate Talking-Head Video（音频先行流程）

用已有分身(用户自训的 或 公共池的)合成口播视频。

**关键设计(v2,音频先行)**:
- **音频拆段、视频不拆**。脚本按句拆成 N 段,**逐段先出音频**给用户试听;用户确认音色 / 内容后,把各段音频拼成整条,**单条连续渲染**出成片。
- **为什么**:实测把长视频拆成段再拼(逐段渲染 + ffmpeg/多场景拼接)会在段边界产生卡顿 / 冻帧;单条连续渲染最流畅。音频侧拆段则让用户能**只重生不满意的那一句**(音频试听免费 / 极廉),不用整条重来。
- 每条视频独立 workspace 目录(`~/digital-human/videos/<video-id>/`),音频段 + 整条音频 + 成片都持久化。详见 [edit.md](edit.md)。

> **关键不变量**:用户**逐段听过并确认**每段音频之前,**绝不进行任何视频渲染**(视频才烧额度)。音频段可反复单独重生(免费 / 极廉),视频只在最终确认后整条渲染一次——这是进入视频的**唯一闸门**。

## 0. 运行环境约束:bash 是**受限沙箱**(必读,否则会撞墙)

agent-runtime 的 bash 有 baseline 安全策略(claude-code 移植,烤进镜像、部署关不掉)。下面这些**会被 soft-deny 拦**,本 SKILL 的所有命令都**不要用**:

- ❌ `$( )` 命令替换(如 `VAR=$(cmd)`、`--text "$(cat f)"`)
- ❌ `for ... do ... done` / `while` 循环,命令内换行,`;` / `&&` / `||` 串多条
- ❌ `nohup ... &` 后台,`> file` / `>>` 重定向,`| jq` / `| sed` / `| wc` 管道

**改用这些沙箱安全的等价做法:**

| 想做的事 | ❌ 别写 | ✅ 改成 |
|---|---|---|
| 读文件内容喂给命令 | `--text "$(cat seg.txt)"` | 先用 **Read tool** 读出文本,再把内容**内联**进命令:`--text "大家好,我是张医生。"` |
| 解析 CLI 的 JSON 输出 | `VAR=$(cmd \| jq -r .x)` | 直接跑 `cmd`(单条),**从它 stdout 的 JSON 里读**你要的字段,后续命令里内联用 |
| 并发跑 N 段 | `for ...; nohup cmd & done` | **LLM 并行 tool_use**:一次性发 N 个独立 bash 调用,每个**只一条命令**,runtime 自动并发 |
| 写文件 | `echo "x" > f` / `cat > f <<EOF` | 用 **Write tool** 写 |

**核心心法**:**一条 bash 只跑一条简单命令**;循环 / 并发 / 取值 / 读写文件,统统交给 **LLM 自己的 tool_use 编排**(Read/Write/并行 bash),不要让 shell 做。下面各步的命令都按这个写;看到旧式 shell-magic 一律按上表翻译。

## 主流程: 用 X 做视频说 Y

### 1. 确定 X 是什么

按 [SKILL.md "LLM 如何区分用户输入是名字还是 external_id"](../SKILL.md) 表格判断:
- 名字 → 在 `~/digital-human/avatars.md` "名字"列模糊匹配
- 新格式 external_id (`dh-[a-z0-9]{8}`) → 直接走 §2
- 存量 ASCII slug (例 `dr-wang`) → 走 [manage.md "存量分身"](manage.md) 流程
- 歧义 → 反问消歧

匹配结果:
- 1 个匹配 → 取该行 external_id,走 §2
- 多个匹配 → 反问消歧("你说的是'张医生'还是'张主任'?")
- 0 个匹配 → 反问"没找到'X',要训新的吗?"(走 [train.md](train.md))

### 2. 反查 voice_id + avatar_id

后端 CLI 要求传 `--avatar` + `--voice` 两个 UUID,不支持 `--external-id` 自动反查,所以**必须先查**:

```bash
gen avatar profile <external_id>
```

跑这一条,从它 stdout 的 JSON 里读两个字段(**别用 `$()`/`jq` 截**,直接看输出):
- `heygen_voice_id` → 后面音频段(`gen voice preview`)用,**内联**进 `--voice` 参数
- `heygen_avatar_look_ids[0]` → 最终视频(audio-driven)用,**内联**进 `--avatar` 参数

> 记住这两个值(在你后续命令里直接写出来),不要存 shell 变量——沙箱里 `VAR=$(...)` 会被拦。

### 3. 创建 video 工作目录

VIDEO_ID = 纯时间戳 `YYYYMMDD-HHMM`(决策表 #11,无 slug v1)。**你(LLM)知道当前时间,直接写出这个 id**,不要用 `$(date)`。

先 `ls ~/digital-human/videos/` 看有没有同名目录;有的话给 id 追加 `-2`/`-3`。然后单条命令建目录:

```bash
mkdir -p ~/digital-human/videos/20260531-1430/segments
```

(后续命令里 `~/digital-human/videos/20260531-1430` 这个路径直接写全,不要存 `$VIDEO_DIR` 变量。)

### 4. 拆段(按句,用于逐段试听)

**拆段目的变了**:不再是规避"长 prompt 让 trained avatar 挂死"(那是旧的逐段视频约束;音频驱动单条渲染已实测能吃长文本)。现在拆段**纯粹为了让用户逐段试听 + 只重生不满意的那一句**。所以按**自然句**拆,一句一段,粒度便于复听 / 重 roll。

text 很短(一两句)→ 也走 segments 流程(单段也存),保持一致性。

**拆段分隔符优先级**(从强到弱依次尝试):

1. **句末标点**:`。`(中文句号)/ `.`(英文句号)/ `?` / `?` / `!` / `!`
2. **句中标点**(若句子过长想再切):`,` / `,` / `;` / `;` / `:` / `:`
3. **空格**(英文 / 长拼接句)
4. 没有任何分隔符的超长句 → 整句作一段(音频无硬字数限制,单条渲染也能吃)

> 旧版的"每段 ≤ 50 字"硬约束**已取消**——那是 text-driven 逐段视频的限制,音频先行下不适用。拆段只为试听粒度,不为绕字数。

**写每段 text 文件**:用 **Write tool** 逐个写(2 位零填充,支持到 99 段),**别用 `echo > seg.txt`**(重定向沙箱拦,见 §0)。每段一个文件:

- `~/digital-human/videos/<VIDEO_ID>/segments/seg-01.txt` ← 内容 `大家好,我是张医生。`
- `~/digital-human/videos/<VIDEO_ID>/segments/seg-02.txt` ← 内容 `今天给大家推荐一款...`
- ……(拆出几段写几个文件)

> 这些 text 文件是 workspace 的真值,§6 出音频时文本你手头就有(直接内联进 `--text`),后续 §6.6 改词 / §6.8 续生才需要 Read 回来。

### 5. 写 meta.md(初版,state=audio generating)

"当前版本"字段是 **state machine** 的 marker:`(audio generating)` → `(audio-review pending)` → `(rendering)` → `final-vN.mp4`。本步写第 1 阶段 placeholder `(audio generating)`,表示音频段还在生成。

跨 session 续接(§6.8)和 manage.md "列我的视频"都靠这字段路由。

```markdown
# Video: <slug from text or topic>

| 字段 | 值 |
|---|---|
| video-id | <VIDEO_ID> |
| avatar | <display_name> (external_id: <external_id>) |
| 创建时间 | <YYYY-MM-DD HH:MM> |
| 当前版本 | (audio generating) |

## 总文本

> <用户给的完整 text>

## Segments

| seg | text | 音频时长 | 音频文件 | 最后更新 |
|---|---|---|---|---|
| 01 | "大家好,我是张医生。" | ~3s | segments/seg-01.wav | <时间> |
| 02 | "今天给大家推荐..." | ~4s | segments/seg-02.wav | <时间> |
| ... |
```

> **不写 voice_id / avatar_id UUID** 到 meta.md —— 用户能看到这个文件,UUID 不冒泡;需要时 LLM 重新 `gen avatar profile` 反查。

### 6. 逐段生成音频(**用 LLM 并行 tool_use,不要 shell 循环**)

每段用 `gen voice preview` 出**音频**(纯音频试听,**不烧视频额度、不单独计费**)。`gen voice preview` 是**同步**的——一条命令跑完就拿到那段 .wav + 它的时长。所以:

**做法:你(LLM)一次性发出 N 个并行 bash 调用,每个就一条命令**,形如:

```bash
gen voice preview --voice <voice_id> --text "大家好,我是张医生。" --output ~/digital-human/videos/<VIDEO_ID>/segments/seg-01.wav
```

- 每段文本从你 §4 拆好的内容**内联**进 `--text`(你手里就有这些句子,不用 `cat`/`$()` 去读)。
- N 个调用**并行发出**(同一轮 tool_use 里多个 bash),runtime 自动并发——等价于以前的"并发派发",但**没有** `for` / `nohup &` / `$()` / `>` 重定向。
- 幂等:已存在的 seg-NN.wav 不用重发。
- 每条命令的输出里有该段的 `duration`、`audio_path` 等——**记下每段时长**(§6.5 要用,不必再 ffprobe)。

> **严禁**:`for ... gen voice preview ... done` 循环、`nohup ... &`、`--text "$(cat ...)"`、`> seg.log`。这些在沙箱里全被拦(见 §0)。并发靠 LLM 并行发多条,不是 shell。

每段独立音频文件,文件名跟 text 零填充对齐(seg-01 ↔ seg-01.wav)。某段命令报错就单独重发那一段。

---

### 6.5 音频 review checkpoint(**核心 — 进入视频的唯一闸门**)

> 实施 [SPEC 2026-05-30 digital-human-audio-first-flow](docs/2026-05-30-digital-human-audio-first-flow.md) §4.3。
> **N==1 单段也走本 checkpoint** —— 音频先行下,单段也要让用户先听过音色 / 内容再出片(视频烧额度,听了再出最稳)。

**Step 1: 更新 meta.md state**:`(audio generating)` → `(audio-review pending)`。

**Step 2: 取每段时长**:用 §6 每条 `gen voice preview` 输出里已经返回的 `duration`(不用再 ffprobe;那是个 `for`+`$()` 循环,沙箱拦)。若确实漏了某段时长,单条 `ffprobe -v error -show_entries format=duration -of csv=p=0 ~/digital-human/videos/<VIDEO_ID>/segments/seg-NN.wav` 补一段即可(一次一条,别写循环)。

**Step 3: 向用户逐段呈现音频供试听 + 列 segments 表**

把**每段文本 + 对应音频文件**都列出来,让用户逐条听:

```text
✅ N 段音频已生成,出视频前请逐段听一下(确认音色 + 内容):

| seg | text | 音频时长 | 试听文件 |
|---|---|---|---|
| 01 | "大家好,我是张医生。" | 2.95s | segments/seg-01.wav |
| 02 | "今天介绍一款..." | 4.12s | segments/seg-02.wav |
| ... |

逐段听完怎么处理?
- "都 OK,出视频吧" → 我把音频拼成整条,渲染最终视频
- "seg-N 改成 X"(改词)/ "seg-N 重生一遍"(同词重 roll) → 只重生那一段音频(免费 / 极廉),改完再回来给你听
- "都不要,重新搞" → 删了用同一段 text 重新生成
```

**Note**:user-facing 文案**禁止泄露内部 §-编号**。用户看不懂章节号,用自然语言。

**Step 4: LLM turn-end 等用户响应**

CC chat 单 turn 内无法同步 block 等输入,必须 turn-end。state 由 meta.md `(audio-review pending)` 持久化,即使用户中途关 session,新 session 也能通过 §6.8 重展 checkpoint。

> **再次强调闸门**:只有用户明确"都 OK 出视频"才进 §7。任意一段被指出要改 → 走 §6.6 只重生那段音频、回到本 checkpoint 重新听。**没听完确认前不渲染视频。**

### 6.6 Checkpoint 内改段(只重生音频,免费 / 极廉,不版本化)

跟 [edit.md](edit.md) 主流程区别:edit.md 改的是**已出片**的视频(要重渲染整条);本段是**出片前**改音频,只重生该段音频,**不触发任何视频渲染**。

**完整 5 步**(state 不变,仍是 `(audio-review pending)`):

1. **编辑距离阈值检查**(沿用 [edit.md §4 决策 #14](edit.md)):
   - 计算 `levenshtein(旧 text, 新 text) / max(len(旧), len(新))`
   - `> 30%` → 反问"确认改吗?";`≤ 30%` → typo/标点微调,静默继续
   - (音频 preview 免费 / 极廉,这里阈值确认主要防误改内容,不是防烧钱)
2. **覆盖 `segments/seg-NN.txt`**(用 **Write tool** 写新文本,别用 `echo >`)+ **重生 `segments/seg-NN.wav`**(一条命令,新文本**内联**):
   - voice_id:若这是新 session、手头没有,先 Read `meta.md` 拿 external_id(形如 `dh-xxxxxxxx`)→ 跑 `gen avatar profile <external_id>` → 从输出读 `heygen_voice_id`(别 `$()`/`jq`)。
   ```bash
   gen voice preview --voice <voice_id> --text "<新 text>" --output ~/digital-human/videos/<VIDEO_ID>/segments/seg-02.wav
   ```
   (其他段音频不动;voice preview 无语速/语气调节,只能改词或同词重 roll)
3. **更新 meta.md** segments 表第 N 行"最后更新"列
4. **显式写 history.md** 一行:
   ```
   - **<当前时间>** [audio-review] 改了 seg-N 音频: '<旧>' → '<新>' (<编辑距离比例 X%>)
   ```
   `[audio-review]` 标记区分出片后的 `[v1]/[v2]`,跨 session 续接时能看到历史改动
5. **回 §6.5 checkpoint 重展**(防止改完 seg-2 又发现 seg-3 也要改的二次 edit)

### 6.7 用户说"都不要"——丢弃当前 video

**二次确认**(rm -rf 不可恢复,跟 manage.md 删视频对齐):

> "确认删除 `videos/<VIDEO_ID>/`?这条还没出片,音频段删了不可恢复。
> 删完会用**同一段 text** 重新生成(每次音色 / 韵律会有细微差异,所以'重新搞'本身有意义)。"

用户确认 → 走(把 `<VIDEO_ID>` 这段路径**写全**,别用 `$VIDEO_ID` shell 变量——沙箱里它没被安全赋过值):

```bash
rm -rf ~/digital-human/videos/20260531-1430
```

回到 §3 用新 VIDEO_ID + **原 text** 重建(不再问用户 text)。

### 6.8 跨 session 续接

用户开新 session 说"出视频 X" / "刚才那条出片" / "继续 X 视频":

```bash
ls -t ~/digital-human/videos/   # 按 mtime 降序
```

逐目录 Read `meta.md` 取"当前版本",**按 state 路由**:

| state | 行为 |
|---|---|
| `(audio generating)` | 音频段还没生完(可能 §6 跑一半中断)→ 走下面"续生音频流程" |
| `(audio-review pending)` | 音频都生完,等用户确认 → **重展 §6.5 checkpoint** |
| `(rendering)` | 已确认、视频在渲染(可能中断)→ 走 §7 重跑渲染(concat 已有则跳过,直接重渲) |
| `(generating)` / `(review pending)`(旧 v1 字面量) | 旧流程(逐段视频)半成品,跟音频先行**不兼容**(段是 .mp4)→ 反问"这条是旧流程的半成品,续接方式已变,要用原文重新生成吗?" 同意 → §3 用原 text 全新生成,**不**走下面续生流程 |
| `final-vN.mp4` | 已出片,跳过(不是未完成视频) |

匹配结果:1 个 → 按 state 决定;多个 → 反问"以下是未完成的:..."(列 state);0 个 → "没找到未完成的视频,要新建吗?"

**`(audio generating)` 续生音频流程**(跟 §6 同款,沙箱安全):

1. `ls ~/digital-human/videos/<VIDEO_ID>/segments/` 看哪些 `seg-NN.txt` 还**缺**对应的 `seg-NN.wav`。
2. voice_id:Read `meta.md` 拿 external_id → `gen avatar profile <external_id>` → 从输出读 `heygen_voice_id`(别 `$()`/`jq`)。
3. 对**每个缺的段**,各发**一条** `gen voice preview`(LLM 并行多条,不写 `for`/`nohup`),文本从对应 `seg-NN.txt` 用 **Read tool** 读出来内联:
   ```bash
   gen voice preview --voice <voice_id> --text "<该段文本>" --output ~/digital-human/videos/<VIDEO_ID>/segments/seg-NN.wav
   ```

**续生完成后**:全部 seg-NN.wav 存在 → 转 state 到 `(audio-review pending)` + 跳 §6.5 checkpoint 让用户逐段试听。**不**直接出片(用户没听过)。

---

### 7. 确认后:拼接音频 → 单条渲染 → final-v1.mp4

仅当 §6.5 用户明确"都 OK 出视频"才进本步。

**Step 1: 更新 meta.md state**:`(audio-review pending)` → `(rendering)`。

**Step 2: 把确认的各段音频拼成整条** —— **必须用 `gen voice concat`**(单条 CLI,内部用 concat filter 重编码、非 stream-copy,避免拼缝杂音;不会撞沙箱):

```bash
gen voice concat --workspace-dir ~/digital-human/videos/<VIDEO_ID>/ --out ~/digital-human/videos/<VIDEO_ID>/full-audio.mp3
```

> 自动扫 `segments/seg-*.{wav,mp3}` 按文件名自然排序拼接(Mode A)。从它 stdout 的 JSON 读 `duration_sec`(别 `$()`/`jq`)。
>
> 🔴 **严禁自己跑 `ffmpeg -f concat ...` 拼音频** —— 即使 §6 撞过 soft-deny,也**绝不**回退到手搓 ffmpeg。`gen voice concat` 就是为此封装的规范 wrapper;手搓 ffmpeg = 绕过抽象 + 易出拼缝问题。这是硬规矩(见 SKILL.md Global Rule 关于 anti-fabrication / 不绕 CLI 抽象)。

**Step 3: 用整条音频单条渲染 avatar 视频**(audio-driven,无 scene 边界):

```bash
gen avatar video --avatar <avatar_id> --audio ~/digital-human/videos/<VIDEO_ID>/full-audio.mp3 -o ~/digital-human/videos/<VIDEO_ID>/final-v1.mp4
```

- 音频驱动:嘴型跟随 `full-audio.mp3`,成片声音 = 用户逐段确认过的那条,分毫不差。
- **单条连续渲染**,不拆 scene → 无段边界卡顿。

**渲染成功后,更新 meta.md "当前版本"**:`(rendering)` → `final-v1.mp4`。

**失败处理**:
- `gen avatar video --audio` 返回 `音频驱动 doctor-video 暂未开放` → 后端音频驱动未启用(feature flag off / 未上线)。**这是部署状态,不是用户错误**。告诉用户"音频驱动出片功能正在灰度,稍后可用",**不要**反复重试。
- 其他渲染失败 → 看 `error_message`,必要时重跑本步(音频已拼好,直接重渲不用重做音频)。

### 8. 写 history.md

用 **Write/Edit tool** 写 `~/digital-human/videos/<VIDEO_ID>/history.md`(别用 `echo >>` / `cat <<EOF`,沙箱拦)。时间用你(LLM)知道的当前时间内联,别用 `$(date)`。

- 若 history.md 已存在(§6.6 期间有过 `[audio-review]` 改动行)→ 用 Edit **追加**一行,保留旧记录:
  ```
  - **2026-06-01 14:30** [v1] 出片 (N 段音频拼接 + 单条渲染)
  ```
- 否则用 Write 新建:
  ```
  # History

  - **2026-06-01 14:30** [v1] 出片 (N 段音频拼接 + 单条渲染)
  ```
```

### 9. 报告 final-v1.mp4 给用户

> "✅ 视频生成完成: ~/digital-human/videos/<VIDEO_ID>/final-v1.mp4
> 共 N 段音频拼成整条、单条连续渲染(无段间卡顿)。
> 想改哪句,说一声——我重生那段音频给你听,确认后重渲一次。"

---

## 没指定分身但要"做个口播视频"(公共 avatar)

公共 avatar 不是用户自己的声音,**不走音频先行试听**(没有"确认音色"的意义)。但仍**单条渲染、不拆段**——这是另一半的卡顿修复(拆段拼接才卡)。

1. 展示 [avatar-catalog.md](avatar-catalog.md) 公共 avatar / voice 清单(用 preview 图或名称,不冒泡 UUID)
2. 让用户选一个 avatar + 一个 voice
3. 走 workspace 持久化(VIDEO_ID 同 §3),但**整段一次渲染**(text 不拆;单条渲染已实测能吃长文本)。**一条命令、文本内联**(别用 `\` 续行换行、别 `$(cat)`——沙箱拦,见 §0):
   ```bash
   gen video --provider heygen --avatar <公共 avatar id> --voice <公共 voice id> --text "大家好,今天给大家介绍……(整段文案内联在这里)" -o ~/digital-human/videos/<VIDEO_ID>/final-v1.mp4
   ```
   (文案是用户给的、你手头就有,直接写进 `--text`;太长也照样内联成一行,不要拆文件再 `cat`。)
4. meta.md state 直接 `(rendering)` → `final-v1.mp4`(无音频 review 阶段);§8 history + §9 报告同主流程

公共 avatar 视频的 `meta.md` "avatar" 字段写 `公共: Abigail (id 不冒泡)` 即可。

> 公共池若想也走"逐段试听",需要先有该 voice 的 `gen voice preview` + 后端公共-avatar 音频驱动支持——**v2 暂不做**,公共路径保持单条 text 渲染。

---

## 可选参数

**音频段(`gen voice preview`)**:`--text-type <text|ssml>`。**注:`voice preview` 不支持 `--voice-speed`/`--voice-emotion`**——音频先行下逐段音频暂无语速/语气调节(改词 / 重 roll 即可;真要语速/情绪是后续 CLI + 上游支持的 follow-up,别凭印象拼 flag,见 SKILL rule #4)。

**公共 avatar 单段视频(`gen video --provider heygen`)**:

| 参数 | 说明 | 默认值 |
|---|---|---|
| `--avatar-style <style>` | normal \| circle \| closeUp | normal |
| `--voice-speed <n>` | 语速 0.5-1.5 | 1.0 |
| `--voice-emotion <emotion>` | Excited \| Friendly \| Serious \| Soothing \| Broadcaster | — |
| `--bg-color <hex>` | 背景色 | — |
| `--caption` | 启用字幕 | — |
| `--title <title>` | 视频标题 | — |

`gen avatar video`(trained avatar)接受 `--avatar` / `--voice` / `--text`(文本驱动)**或** `--avatar` / `--audio`(音频驱动)/ `--title` / `-o`。

---

## 训完立即 demo(可选)

如果用户在原始训练请求里说了想"看效果 / 做一段试试 / 给我个 demo":训完直接走 §主流程,text 用用户给的或默认 `"大家好,我是 X"`(X = 名字)。短 demo 也走音频先行(让用户先听音色)。

**降级**:如果 trained avatar 还在 still-processing(`gen avatar video` 返回错误)→ 音频段照常出(voice 已训好),但视频改用公共 avatar + 该音频单条渲染应急,告诉用户:
> "分身的视觉部分还在最后定型(几十分钟内会好),先用通用形象演示。**这条 demo 仅证明 voice 训成,不展示真分身效果** — 几小时后用 'X 做个视频说 Y' 重试就能用真分身了。"

> **注意区分**:这是 still-processing 期间的**临时降级**,不算 mix-and-match。

---

## Advanced: mix-and-match(参考实现,**不**进 SKILL.md 主流程)

> [workspace SPEC §2](../../../../docs/superpowers/specs/2026-05-14-digital-human-workspace-naming.md) 已声明 mix-and-match 是非目标。用户主动要求时再让 advanced agent 临时执行。

例:"用张医生的声音 + 公共 Aditya 形象":逐段用张医生 voice 出音频 → 拼接 → `gen video --provider heygen --avatar Aditya_public_4 --audio full-audio.mp3`(若公共路径支持 --audio;否则用 `--voice <张医生.voice_id> --text` 单条文本渲染)。LLM 主动告诉用户:"这是参考实现,未来若呼声高再进主流程。"

---

## Common Mistakes

| ❌ Mistake | ✅ 正确做法 | 为什么 |
|---|---|---|
| 没让用户听音频就出视频 | 必须先 §6.5 逐段试听 + 确认,才 §7 渲染 | 视频烧额度;音色 / 内容问题在音频阶段(免费)就该抓出来 |
| 把视频拆段渲染再拼接 | 整条音频**单条渲染**(`gen avatar video --audio`) | 拆段拼接是卡顿根因;单条连续渲染最流畅 |
| 用 `gen avatar video --text` 出 trained avatar 视频 | 走音频先行:`voice preview` 逐段 → `voice concat` → `avatar video --audio` | text 驱动跳过了试听闸门,且回到逐段问题 |
| `gen voice concat` 用 mp3 `-c copy` 拼 | 用 `gen voice concat`(内部 concat filter 重编码) | stream-copy 拼 mp3 有 priming/gap 杂音 |
| 把 voice_id / avatar_id UUID 报给用户 | 只回 mp4 路径 + "用 X 做了" | UUID 不冒泡是核心 UX 原则 |
| 公共"做个口播"也拆段 | 公共走 `gen video --provider heygen` **单条** text 渲染 | 公共不是用户声音、不试听;但单条渲染仍要(防卡顿) |
| segments 写 ephemeral 目录(`/tmp`) | 写到 `~/digital-human/videos/<id>/segments/` | 用户后续想改一句,workspace 是单一真值 |
| `gen avatar video --audio` 报"暂未开放"当成 bug 反复重试 | 这是后端灰度状态,告知用户稍后可用,不重试 | 音频驱动有 feature flag,未上线时正常拒绝 |
