# Training Workflow

按顺序执行,每步必走。把用户上传的真人视频训成数字分身(voice + avatar),写入 `~/digital-human/avatars.md`。

> ⚠️ **沙箱 bash 须知**(同 [generate.md §0](generate.md) / [SKILL.md Global Rule #7](../SKILL.md)):agent-runtime 的 bash **软拒绝** `$( )` 命令替换、`for`/`while` 循环、命令内换行(含 `\` 续行)、`;`/`&&`/`||` 串多条、`> file`/`>>`/heredoc(`<<EOF`)重定向、`| jq`/`| sed`/`| grep` 管道、`nohup &`。所以本文档:**取值靠 LLM 自己读单条命令的 stdout JSON**(不 `$()`/`jq`)、**写文件用 Write/Edit tool**(不 heredoc / `echo >`)、**轮询用单条命令逐次跑**(不 `while`)、**所有 id / 路径内联进命令**(不存 `$VAR`)。

## 0. 工作空间初始化(首次训练)

```bash
mkdir -p ~/digital-human/training ~/digital-human/videos
```
(`mkdir -p` 接受多个路径参数,一条命令建两个目录,不用 `{a,b}` brace 展开。)

如 `~/digital-human/avatars.md` 不存在,**创建**含 SKILL.md 模板的空表(见 SKILL.md "工作空间"段)。

`training/` 用于归档每个分身的训练源素材(原视频 / 提取音频 / 训练元数据);`videos/` 用于每条生成视频的 segments + final。这两个目录的存在让用户后续可以**改某段视频不用全重生**(见 [edit.md](edit.md))、**重训不用重传视频**(下文 §4.5)。

## 1. 询问名字

LLM 主动问:

> "训完叫什么名字?(中英文皆可,1-40 字,以后用这个名字找它)"

用户答 → 直接作为"名字"列的值,**不做任何 normalize / 转换 / 拒绝**。

约束(由 LLM 兜底):
- 长度 1-40 字符(Unicode 可见字符,禁控制字符 `\x00-\x1f`)
- 不能跟 `~/digital-human/avatars.md` 现有"名字"列重复

**重名处理**:
- 反问"你已经有'张医生'了,起个别的(例如'张医生2'/'张主任')"
- **不**提供"覆盖"选项(v1 不支持,见 [workspace SPEC §6 #6](../../../../docs/superpowers/specs/2026-05-14-digital-human-workspace-naming.md))

## 2. 内部生成 external_id

**由 LLM 自己生成**(你直接想一个,**别用 `$(openssl ...)`/管道**——沙箱拦):格式 `dh-` + 8 位随机小写字母数字(例 `dh-a7f2k9m1`)。生成后**记住这个值**,跨段(§4.5 / §5 / §6 / §6.5 / §8 / §9)在每条命令里**直接写全**,不存 `$EXTERNAL_ID` 变量(沙箱里没法安全赋值,且大小写混用会取空值导致路径错误)。

**碰撞处理**(随机 8 位,碰撞概率低但非零,后端 `external_id` 有 UNIQUE 约束 `migrate.ts:44`):
- onboard insert 失败且 error_code 表明唯一冲突 → 重新生成 + 重试
- **最多重试 3 次**,3 次失败 → 报错给用户

## 3. 视频规格检查

```bash
ffprobe -v quiet -print_format json -show_format -show_streams <video-path>
```

记录:
- `duration` 总时长
- `width` × `height` 分辨率
- `audio` 流是否存在 + sample_rate / channels

判定:

| 项 | 阈值 | 不达标动作 |
|---|---|---|
| 视频时长 | < 30s | 中止;要求重传 ≥ 30s 视频 |
| 视频时长 | 30-119s | 警告"voice OK,但 avatar 质量可能受影响,推荐 ≥ 2min" |
| 分辨率 | < 720p | 警告但继续 |
| 音频流 | 缺失 | 中止;视频里没声音怎么训 voice |

**不前置检测多人 vs 单人** — ffprobe 拿不到 speaker count。多人混杂等 voice clone fail 时按 `error_message` 反馈。

## 4. 背景判断 + 决策

spec §13.4 关键约束:训练系统会把整段视频画面(包含背景)整体作为 avatar look 内容,video gen 时**无法用 `background` 参数替换**。

**直接问用户三选一**(不要抽帧 + Read 图判断 — 视觉判断不稳定且贵):

> "训练前确认:这段视频的背景是?
> A. **白墙 / 棚拍 / 绿幕 / 单色背景**(干净)
> B. **标准固定场景**(诊室 / 办公桌 / 医院前台 — 这个背景将永远 baked 进分身,后续无法换)
> C. **户外 / 商场 / 多人 / 杂乱**(脏)"

| 用户选 | 决策 |
|---|---|
| A | `--clean-bg` |
| B | 二次确认:"分身的所有未来视频背景都会是这个场景,可接受吗?" → 接受用 `--accept-baked-bg`;不接受 → 让用户重拍 A 类背景 |
| C | 强烈劝退,推荐重拍干净背景。如用户坚持产出 → `--accept-baked-bg` + 明确告诉产出视频背景永远是录制时场景 |

**禁止**:
- ❌ silent 默认 `--clean-bg`(错判脏背景为干净 → 产出脏数据)
- ❌ 自己抽帧用图像识别判断(视觉判断不稳定 + 浪费 token + 用户答更准)

## 4.5 归档源视频(必须在 §5 拆音频之前)

把用户上传的视频复制到 workspace,后续步骤全部从复制好的副本走(把 `<external_id>` 换成 §2 你生成的那个 id,**写全**,别用 `$EXTERNAL_ID`)。两条单命令:

```bash
mkdir -p ~/digital-human/training/dh-a7f2k9m1
```
```bash
cp "<用户上传的视频路径>" ~/digital-human/training/dh-a7f2k9m1/source-video.mp4
```

**为什么必须先复制**:
- Claude Code session 里 attach 的视频通常在临时目录(`/tmp/...` 或类似),**session 结束就消失**
- 后续步骤(拆音频、ffprobe、onboard 上传)都依赖这个文件存在,临时附件中途消失会全部失败
- 先 cp 到 workspace,等于把素材"焊死"在本地,后续就算 session 关了重启也能续做

**为什么用 `cp` 而不是 `ln`(hardlink)或 `mv`(移动)**:
- `mv` 删原文件 → 用户原始路径丢失,违和
- `ln` 跨 filesystem 会失败,而且加 LLM 出错面(决策详见 SPEC `2026-05-15-digital-human-segment-persistence.md` 决策 #13)
- `cp` 简单可靠,磁盘代价可接受(2-5 min 1080p 视频 ~ 50-200 MB,个人开发机能扛)

**用户原始路径不动**,只是我们这边存一份副本。

## 5. 提音频(从复制好的 source-video.mp4)

`gen avatar onboard` 接受 `<audio-file> <video-file>` 两个文件参数。从 §4.5 复制好的 `source-video.mp4` 提音频(**不再从用户原始路径提**,因为原始路径可能中途消失)。**一条命令、路径写全、不换行**(`\` 续行沙箱拦):

```bash
ffmpeg -i ~/digital-human/training/dh-a7f2k9m1/source-video.mp4 -vn -acodec pcm_s16le -ar 44100 -ac 1 -t 90 ~/digital-human/training/dh-a7f2k9m1/source-audio.wav
```

取前 90 秒单声道 WAV,够 voice clone。路径固定 = `training/<external_id>/source-audio.wav`,不用 `mktemp`(单分身一个 audio,无并发覆盖问题)。

## 6. 触发训练(onboard)

**一条命令、路径 + id 写全、不换行**(`\` 续行沙箱拦;`--clean-bg` / `--accept-baked-bg` 按 §4 决策二选一,不能省):

```bash
gen avatar onboard ~/digital-human/training/dh-a7f2k9m1/source-audio.wav ~/digital-human/training/dh-a7f2k9m1/source-video.mp4 --external-id dh-a7f2k9m1 --clean-bg
```

> **不要传 `--display-name`** — 后端 CLI [`doctor.ts:36`](packages/cli/src/commands/doctor.ts) 不支持这个 flag,commander 会拒绝未知选项报错。display_name 唯一存在于 `~/digital-human/avatars.md`,backend 完全不感知。

返回 JSON:
```json
{ "external_id": "dh-a7f2k9m1", "voice_task_id": "<uuid>", "avatar_task_id": "<uuid>", "status": "pending" }
```

如返回唯一冲突错误 → 回到 §2 重新生成 external_id,重试(最多 3 次)。

## 6.5 写 train-meta.md(归档训练元数据)

onboard 提交成功后,从它 stdout 的 JSON 里读 `voice_task_id` / `avatar_task_id`(自己读,别 `$()`/`jq`)。用 **Write tool** 写训练元数据到 `~/digital-human/training/<external_id>/train-meta.md`(**别用 `cat <<EOF` heredoc**——沙箱拦)。所有值(名字 / external_id / 两个 task_id / 背景决策 / 今天日期 / §3 ffprobe 的时长分辨率)你手头都有,**内联**进模板:

```markdown
# Training: 张医生 (external_id: dh-a7f2k9m1)

| 字段 | 值 |
|---|---|
| display_name | 张医生 |
| external_id | dh-a7f2k9m1 |
| 训练时间 | 2026-06-01 |
| voice_task_id | <onboard 输出的 voice_task_id> |
| avatar_task_id | <onboard 输出的 avatar_task_id> |
| 背景决策 | --clean-bg(或 --accept-baked-bg) |
| 源视频时长 | (从 §3 ffprobe 输出取) |
| 源视频分辨率 | (同上) |
| preview_image_url | (训完 §8 拉到后回填这一行) |
```

(日期用你 LLM 知道的当天真实日期,别 `$(date)`。)

**为什么单独写文件**:
- avatars.md 是分身索引(用户看 + LLM 用),只放最关键字段(名字 / external_id / 训练时间 / preview)
- train-meta.md 是分身**完整训练记录**,含 task_ids / 背景决策 / 源视频 specs 等用户不关心但**重训时关键**的信息

## 7. Poll 直到完成

**沙箱里没有 `while` 循环**——靠 LLM **逐次发单条命令**轮询(把 task_id 内联进命令,从输出 JSON 自己读 `status`,别 `$()`/`jq`)。每轮两条:

```bash
gen task get <voice_task_id>
```
```bash
gen task get <avatar_task_id>
```

- 读两条输出里的 `status`。两个都 `completed` → 进 §8;任一 `failed` → 看 `error_message` 处理(见下)。
- 都还在 `pending`/`processing` → 等一会再来一轮。可选单条 `sleep 15`(**别** `sleep && gen ...` 串、别写 `while`),然后重发上面两条;或直接隔一轮再查。
- **上限 ~30 min**(avatar train 慢边界):LLM 自己记开始时间,超时仍未完成 → 告诉用户"训练超时,稍后用'X 训好了吗'再查"(state 不丢,§profile 可续查),不要无限轮询。

> **Fallback**:`gen task get` 不可用时,单条 curl 查后端(token / 域名内联,**别管道接 `| jq`**,直接看返回 JSON):
> ```bash
> curl -s -H "Authorization: Bearer <token>" "<generation-api>/api/task/<task_id>"
> ```

期望耗时:
- voice clone:30s - 2 min
- avatar train:5 - 30 min
- spec §13.2:trained look 在训练完成后还有短期 race(几分钟到 1 小时);**onboard 阶段不阻塞**

任一 fail → 看 `error_message`:
- voice clone fail → 大概率音频质量(噪声 / 多人混杂)→ 提醒用户重传清晰音频
- avatar train fail → 视频问题(时长 / 分辨率 / 内容)→ 给具体 error 让用户判断

## 8. 拉 profile 拿 preview

两个 task 都 completed 后(external_id 内联):

```bash
gen avatar profile dh-a7f2k9m1
```

返回(关键字段):
```json
{
  "external_id": "dh-a7f2k9m1",
  "heygen_voice_id": "f7a8ea9f...",
  "heygen_avatar_look_ids": ["04182555..."],
  "voice_status": "complete",
  "avatar_status": "completed",
  "preview_image_url": "https://...preview.jpg"
}
```

> DB 字段名 `heygen_*` 是历史命名,代码层不动。**LLM 给用户报告时不冒泡 UUID**(只展示名字 + preview)。

**回填 `train-meta.md`**:把 §6.5 写的 train-meta.md 里 `preview_image_url` 那行补上真值(profile 拿到的 URL)。

(可选)下载 preview 缓存到本地(url + 路径内联,单条命令):
```bash
curl -s -o ~/digital-human/training/dh-a7f2k9m1/preview.jpg "<preview_image_url>"
```
加快"列我的分身"渲染速度,避免每次都打外网。

## 9. 追加一行到 `avatars.md`

用 **Read + Edit tool** 往 `~/digital-human/avatars.md` 末尾追加一行(别用 `echo >>`)。日期用你 LLM 知道的**当天真实日期**(别 `$(date)`,别凭印象):

```
| <用户给的名字> | <external_id> | <TODAY> | <preview_image_url> |
```

## 10. 给用户报告

> "✅ 张医生 训练完成 [preview 图]
> 训练素材已存档到 `~/digital-human/training/张医生/`,以后想重训不用再上传视频。
> 以后说'用张医生做视频说...'即可。"

**不输出** voice_id / avatar_id / external_id(UUID 全部不冒泡)。

---

## Edge cases

### 1. 用户没给视频但说"训练 X"

反问:"X 的视频附件没看到,把视频发我(2-5 min,正面拍摄,干净背景)。"
**不要** 凭印象搜索 X 的公开视频去训(法律 + 同意书风险)。

### 2. 用户上传多个视频

主动列出最近上传的视频文件(文件名 + 时长),**让用户选哪一个用于训练**。不要假设最后一个 = 训练用。

### 3. 用户给了视频但意图模糊

视频可能是"复刻这条视频" / "把这条视频翻译" / "训分身"。如果不明确是后者,**不要触发本 skill**。问一句:
> "你想要 (a) 用这条视频训练 X 的数字分身(以后用分身做新视频),还是 (b) 复刻这条视频做产品宣传 / (c) 把视频翻译成其他语言?"

分别走 digital-human / video-gen / video-gen 视频翻译。

### 4. 用户中途改主意

- 想换视频 → 旧 task 让它跑完(训练资源已扣,不阻塞);用户拿新参数重新走 §1-§7
- 想取消 → `gen task cancel <task_id>` 各自 cancel(尽力而为,上游可能已 in-flight 不可中止)

---

## Common Mistakes

| ❌ Mistake | ✅ 正确做法 | 为什么 |
|---|---|---|
| silent 默认 `--clean-bg`(§4) | 用户三选一 → 显式 `--clean-bg` 或 `--accept-baked-bg` | 错判脏背景为干净 → baked 进 avatar look,后续 video 永远换不掉背景(spec §13.4) |
| 抽帧用图像识别判断背景(§4) | 直接问用户三选一 | 视觉判断不稳定 + 浪费 token + 用户答更准 |
| 让用户起 ASCII slug 当名字(§1) | 用户起的就是友好名字,external_id 由 LLM 内部生成 | 让用户记英文 slug 反人性;workspace 文件已经隔离了 ASCII 约束 |
| 给 `gen avatar onboard` 传 `--display-name`(§6) | 不要传,commander 会拒未知选项报错 | 后端 CLI 没这个 flag([doctor.ts:36](packages/cli/src/commands/doctor.ts));display_name 只在 workspace markdown |
| 凭印象搜公开视频去训(§Edge 1) | 反问用户上传视频 | 法律 + 同意书风险,无授权训练他人形象 |
| 假设最后一个上传的视频 = 训练用(§Edge 2) | 列出所有让用户选 | 错训不该训的人 |
| 视频含糊意图直接触发 skill(§Edge 3) | 三岔反问 (a) 训分身 / (b) 复刻 / (c) 翻译 | 错走训练流程浪费训练配额(训练成本 > 复刻) |
| 没视频但有意图就调 onboard | 反问"把视频发我",并明确质量要求 | onboard 没视频会立即失败,白调一次 |
| 训完报告里写 voice_id / avatar_id UUID | 只展示名字 + preview | UUID 不冒泡是核心 UX 原则 |
| 跳过 §9 写 avatars.md | 训完必须写一行 | 不写下次会话用户说"用张医生"找不到,得记 dh-* slug |
