# @speclip/pi-talking-head

给 [Pi](https://github.com/earendil-works/pi) 用的口播剪辑决策包。它读取 `pi-speech` 风格的词级时间戳，结合整句上下文识别词间停顿、语气词和相邻重复，生成保守的 A-roll 剪辑方案，并在查找素材前把 B-roll 候选压缩成“最小充分画面”。

它不直接调用 FFmpeg。最终输出是 `pi-media` 的通用 timeline EDL，由 `pi-media` 负责素材校验、不可变 revision、渲染和验收。

## 为什么这样拆

- `pi-talking-head`：决定哪里该剪、气口留多少、B-roll 为什么出现。
- `pi-media`：执行任意来源的通用 EDL，保证路径安全、素材哈希、渲染和凭证。
- `pi-speech`：提供词级 `beginMs` / `endMs` 转录。

表达整理由 Agent 判断，插件校验取舍记录和时间线的一致性；候选匹配和技术验收都不能替代语义判断或真实试听。

## 环境要求

- Node.js 22.19+
- Pi 0.84.1–0.85.x
- `@speclip/pi-media` 0.4.0+（本版配套渲染器：保持主素材帧率，输出实际帧/样本时间映射与渲染凭证；需单独升级）
- 一份带词级时间戳的 JSON 转录；格式兼容 `@speclip/pi-speech`

## 安装和验证

### 0.3.1：减少工具交接中的返工

- BGM `mix` 按内容比较，不再因对象字段顺序不同误报回执不匹配；原有哈希算法保持不变。
- A-roll 未变时，`talking_head_apply` 可传 `inheritBroll: true`，省略 `broll/continuityPlanReceipt`，添加音乐无需重新匹配和选择 B-roll。原样提交旧完整 placements 也能复用；素材、清单、视觉证据、回执和当前连续性策略仍重新校验。
- `talking_head_broll_match/select` 支持 `needId + continuityPlanReceipt`，不必重抄需求或补写 mask-cut 派生字段。完整 `need` 仍支持，两者不能同时传。
- 选窗失败返回错误代码、候选起点、已有证据帧、尾部阈值和下一步，减少查源码与猜参数。

新调用示例与恢复方式见 [工具交接说明](skills/talking-head-edit/references/tool-handoffs.md)。本版保留正式 0.3.0 的渲染器要求、默认相对响度模式及项目/回执格式；不迁移另行维护的 Speclip 私有 quality 版本。

```bash
npm install
npm run check
pi install ./
```

## 工作流

### 1. 创建口播项目

```js
talking_head_create {
  projectId: "launch-video",
  sourcePath: "raw/launch.mp4",
  transcriptPath: "transcripts/launch.json",
  sourceDurationMs: 93224, // 来自 media_probe
  cutThresholdMs: 500,
  headPaddingMs: 50,
  tailPaddingMs: 80
}
```

初剪建议只收紧至少 500ms、且没有语气词或表达边界保护信号的词间停顿。每个保留片段前留 50ms、后留 80ms，避免切掉辅音、尾音和自然气口。停顿还会分为：

- `safe`：至少 400ms，通常可以切。
- `review`：150–399ms，必须结合语义和画面判断。
- `unsafe`：少于 150ms，默认不切。

若停顿紧邻候选语气词，或位于问号、感叹号等表达边界之后，即使超过阈值也会降级为 `review`，不会进入默认自动剪辑。ASR 分段边界也先进入复核。工具返回 revision 1、内容与停顿摘要、`editorialStatus: draft` 和 `previewMediaOperation`；初始方案不是完成版。50/80ms 是初始参考，最终每个接点按原音和表达节奏决定。

### 2. 分页检查整句和编辑候选

```js
talking_head_get {
  projectId: "launch-video",
  sentenceOffset: 0,
  sentenceLimit: 20,
  pauseOffset: 0,
  pauseLimit: 50,
  fillerOffset: 0,
  fillerLimit: 50,
  repetitionOffset: 0,
  repetitionLimit: 50,
  contentOffset: 0,
  contentLimit: 50,
  includeWords: true,
  includeTranscriptText: true
}
```

工具会分页返回整句上下文、停顿、语气词和相邻重复；需要判断整段结构时可显式取得完整转录文本。`啊`、`额`、`嗯` 等只会成为 `review` 候选，Agent 必须判断它是口癖、语义成分还是刻意表达，不能自动删除。文本标点只能提供低置信度且可以并存的表达线索，不等同于声学情绪识别。

只有调用这个工具时才会把这些证据放进当前会话上下文；安装 package 不会把整份转录常驻注入上下文。

### 3. 先整理完整表达并提交编辑计划

“气口剪辑”默认包括全片检查、重录取舍、无效起句和重复讲述清理、保留独有信息，再处理停顿。只有用户明确要求仅处理间隔时，才使用 `scope: pauses-only` 并在 `scopeReason` 中记录原要求。

新增的 `contentCandidates` 提示跨句重复短语、近邻相似段落和重新起句，全部为低置信度 `review`。它们不是完整问题清单。`diagnostics` 会报告首尾相接的时间戳比例和候选截断：大量零间隔不能证明原音没有句内停顿。`semanticBoundary: unknown` 表示 ASR 分段没有可靠语义边界证据。

Agent 阅读完整转录、检查原素材之后，先确定输出 A-roll 顺序，再调用：

```js
talking_head_editorial_plan {
  projectId: "launch-video",
  expectedRevision: 1,
  aroll: plannedAroll,
  plan: {
    scope: "full",
    reviewedSentenceIndexes: allReviewedSentenceIndexes,
    sourceReview: { method: "text-and-visual", notes: "已比较完整表达；尚未试听成片" },
    decisions: reviewedDecisions,
    uniqueInformation: retainedUniqueInformation,
    segmentReasons: plannedSegmentReasons,
    unresolvedIssues: []
  }
}
```

- `decisions` 每项包含 `id`、`kind`（`retake/false-start/filler/pause/other`）、`action`（`edit/keep/review`）、`candidateIds`、`ranges` 和 `reason`。`ranges` 每项为零起始且首尾包含的 `startWordIndex/endWordIndex`，以及 `disposition: keep/remove/review`。自行发现的问题可不引用候选；纯停顿候选可以没有词区间。
- 全部候选必须有决定；每个删掉的词必须有对应 disposition；声明保留或删除的词必须与 A-roll 一致。程序检查记录和时间线的一致性，不能验证文字理由是否真实反映听感。
- `uniqueInformation` 每项包含 `id/startWordIndex/endWordIndex/reason`，所指词必须在输出中。支持把前一遍独有内容插入后一遍完整表达。空列表表示比较后确认没有独有信息。
- `segmentReasons` 每项为 `segmentId/reason`，必须覆盖全部输出片段。`reviewedSentenceIndexes` 必须覆盖全部 ASR 分段。
- 保存返回的 `editorialPlanReceipt`，应用时原样提交。它绑定项目、revision、源文件、转录、A-roll 顺序和编辑计划。A-roll 改动必须重新规划；仅 B-roll/BGM 改动可以沿用当前 revision 已保存的内容审阅。

| 状态 | 输出与含义 |
| --- | --- |
| `draft` | 没有编辑计划，仅返回 `previewMediaOperation` |
| `needs-review` | 有未决问题或 review 决定，仅返回预览 |
| `pauses-only-reviewed` | 用户明确限制的间隔处理，仅返回预览，不代表完整表达整理 |
| `content-reviewed` | 完整表达计划通过一致性校验，返回 `mediaOperation`；`listeningStatus` 仍为 `pending` |

允许随时渲染明确标注的草稿小样，但不能把草稿或技术检查成功称为完整剪辑验收。旧快照仍可读取；没有编辑计划的旧版本按草稿对待，旧分析会从未变更的转录重新计算且不改写旧快照。依赖 create/get/apply 固定返回 `mediaOperation` 的调用方需要迁移到上述状态分支。

### 4. 先规划 B-roll 视觉连续性

在查找素材前，把最终 A-roll 和所有 B-roll 需求交给连续性规划工具：

```js
talking_head_broll_plan {
  projectId: "launch-video",
  aroll: [
    { id: "hook", sourceStartMs: 50, sourceEndMs: 4120 },
    { id: "answer", sourceStartMs: 4860, sourceEndMs: 13200 }
  ],
  needs: [
    {
      id: "product-a",
      outputStartMs: 1000,
      outputEndMs: 3000,
      speechText: "接下来第一步，我们先打开设置页面",
      visualCueText: "打开设置页面",
      necessity: "essential",
      purpose: "demonstrate",
      searchTerms: ["设置", "打开"],
      reason: "展示第一步"
    },
    {
      id: "product-b",
      outputStartMs: 3300,
      outputEndMs: 5200,
      speechText: "然后第二步，在列表里选择设备",
      visualCueText: "选择设备",
      necessity: "supporting",
      purpose: "demonstrate",
      searchTerms: ["设备", "选择"],
      reason: "展示第二步"
    }
  ],
  cutCoverBeforeMs: 250,
  cutCoverAfterMs: 500
}
```

第一次分析时，开场、结论、个人观点、情绪和转折默认留在 A-roll。`speechText` 保存完整句子上下文，`visualCueText` 只写真正需要被看见的关键词；输出区间也只覆盖这个最短有效画面。只有“不看画面就难以理解或验证”的需求才能标为 `essential`，其余使用 `supporting`。

规划结果同时返回 `plan.selection`、`plan.coverage` 和 `continuityPlanReceipt`。规划器会先删除没有信息增量的 supporting 转场，并确保观看连续组不超过 8000ms、结尾至少保留 3000ms A-roll。两个 B-roll 之间不足 3000ms 的 A-roll 会按同一个观看连续组计算，但只有不超过 500ms 的闪屏才会被直接衔接覆盖。`essential` 违反连续性规则时会要求 Agent 缩短、移动或拆分画面，而不是直接查素材。`plan.selection.omittedNeeds` 只用于解释淘汰原因，后续严禁匹配；素材查找只能使用 `plan.needs`。v4 凭证会绑定最终入选区间和 A-roll 可见性策略，`talking_head_apply` 会重新核对。旧版快照仍可读取；v1-v3 凭证若包含 B-roll，应用前必须重新规划为 v4，不能绕过新规则。

规划结果处理两类问题：

- **首轮稀疏化**：先按 `transition → establish → explain → demonstrate → evidence` 的顺序淘汰 supporting 候选；`mask-cut` 和明确标注的 essential 不会被静默删除。完整语义只是最大上下文边界，不代表要用 B-roll 覆盖整句话。
- **B-roll 闪屏**：只有入选的两个 B-roll 之间只露出不超过 500ms 的 A-roll 时，`plan.needs` 才会把前一个需求延长到后一个需求的起点。被淘汰的候选不会参与衔接。
- **气口剪辑跳转**：A-roll 相邻片段的源时间不连续时会生成 `jumpCuts`。未覆盖项标为 `review`，只表示 Agent 需要查看实际画面。只有确认跳点视觉上突兀时才能添加 `purpose: "mask-cut"`，保存工作区内的审阅图片或视频，并提交 `visualReview: { decision: "mask-with-broll", reviewedJumpCutOutputMs, artifactPath }`；规划器会计算文件哈希并绑定到 v4 凭证，应用时再次校验。否则继续保留 A-roll。
- **时长与覆盖控制**：不能把 B-roll 固定为三秒，也不能把完整句子直接变成覆盖区间。总覆盖量由 Agent 根据主题和画面信息增量判断，不设固定比例目标。硬约束是观看连续组不超过 8000ms、结尾至少保留 3000ms A-roll。这里的 3000ms 只用于判断 A-roll 是否形成有效人物窗口，不规定 B-roll 本身的固定时长。
- **避免重复**：同一素材 SHA-256 在一个时间线中只能使用一次，即使选择的是不同源时间段。每次选段还必须按实际画面填写 `visualIdentity` 的主体、动作、景别和角度；不同文件只要画面身份相同也会被拒绝。选择凭证会把该身份与素材、manifest 和时间戳一起哈希绑定。

是否加入 B-roll 只看当前画面能否增加理解或证据；不要为了达到某个覆盖比例而添加素材。

### 5. 文件名优先筛选 B-roll

只使用 `plan.needs` 中入选的最短有效区间扫描素材目录，绝不为 `plan.selection.omittedNeeds` 查找素材：

```js
talking_head_broll_match {
  projectId: "launch-video",
  assetDirectory: "assets/broll",
  recursive: true,
  maxFiles: 1000,
  maxEntries: 20000,
  maxDepth: 12,
  maxCandidates: 5,
  candidateOffset: 0,
  needId: plan.needs[0].id,
  continuityPlanReceipt
}
```

每一个 `plan.needs` 都必须单独执行匹配。保存返回的 `matchReceipt`，后续只能从该次返回的候选页中选择素材。

该工具只读取目录项和文件名，不解码、不探测、不哈希视频。它最多返回限定数量的候选，并给出三种结果：

- `filename-direct`：一个文件名唯一且明确命中，只检查这一条素材。
- `filename-shortlist`：多个名字可能匹配，只检查返回的 shortlist。
- `visual-fallback`：文件名没有有效语义，再对限定 shortlist 使用低成本联络表。

扫描同时受 `maxFiles`、`maxEntries` 和 `maxDepth` 约束并支持取消。完整扫描时，若当前 shortlist 都不合格，可使用非空的 `nextCandidateOffset` 读取下一批。结果一旦截断，工具不会声称唯一命中，也不会返回可复用的分页游标；此时应缩小素材目录后重扫，避免文件系统枚举顺序造成候选漂移。

确定素材后才调用 `media_probe` 和 `media_contact_sheet`。长素材先低密度定位大致范围，再只对候选范围高密度抽帧；必须使用 `contact_sheet_manifest.json` 的真实时间戳，不能从 PNG 猜时间。找到连续可用且覆盖完整成片窗口的片段后，验证并生成 placement：

```js
talking_head_broll_select {
  projectId: "launch-video",
  needId: plan.needs[0].id,
  continuityPlanReceipt,
  matchReceipt,
  assetPath: "assets/broll/mouse-demo.mp4",
  manifestPath: "analysis/mouse-demo/contact_sheet_manifest.json",
  selectedStartMs: 12400,
  evidenceTimestampsMs: [12400, 13900, 15400],
  fit: "cover",
  visualIdentity: {
    subject: "鼠标",
    action: "手部移动鼠标",
    shotScale: "close-up",
    angle: "top-down"
  }
}
```

`match` 和 `select` 都会先验证 v4 `continuityPlanReceipt`，被首轮淘汰或被手动改回完整句子范围的 need 会在读取素材前直接拒绝。`select` 不接受未出现在对应 `matchReceipt` 候选中的素材；`apply` 会再按计划中的同一 need 完整复核项目、revision、计划、need 和候选素材。随后工具验证素材 SHA-256、manifest 分析范围、起始帧和覆盖片段尾部的证据时间戳，并把匹配来源与 `visualIdentity` 一起写入选择哈希。placement 内含选择凭证；不要手写、修改或删除其中字段。0.1.8 生成的旧 placement 不含 `matchReceipt`，应用前需要重新匹配并选择。

### 6. 用波形图和标准响度证据添加 BGM

先让 `pi-media.media_audio_analyze` 分析两份音频证据：原视频传入与最终 A-roll 完全一致的有序 `ranges`，BGM 则分析完整文件。工具会生成带 `HH:MM:SS.mmm` 绝对时间戳、短时 LUFS 曲线的波形 PNG，以及 EBU R128 / ITU-R BS.1770 manifest。

```js
talking_head_bgm_plan {
  projectId: "launch-video",
  aroll,
  voiceAnalysisPath: "analysis/voice/audio_analysis_manifest.json",
  musicAnalysisPath: "analysis/bgm/audio_analysis_manifest.json",
  targetMusicBelowDialogueLu: 12
}
```

规划器根据真实时长返回 `trim` 或 `loop`，并绑定目标 LU 差值，不接受音量百分比。默认使用 **12 LU**：这会在 sidechain 再次压低人声下方的 BGM 之前保留足够可听度。只有用户明确要求更轻的氛围底，或音乐本身特别密集、明亮时，才提高到 14–18 LU；不要把 18 LU 当成通用的“更安全”默认值。Agent 打开完整 BGM 波形 PNG 得到起止时间后，必须再调用一次 `media_audio_analyze`，只分析这个最终选中窗口；不能拿整首曲子的平均 LUFS 代替片段响度。随后提交视觉判断和选中窗口的 manifest：

```js
talking_head_bgm_select {
  projectId: "launch-video",
  aroll,
  planReceipt,
  selectedMusicAnalysisPath: "analysis/bgm-window/audio_analysis_manifest.json",
  selectedStartMs: 12_000,
  selectedEndMs: 42_000,
  evidenceTimestampsMs: [12_000, 42_000],
  fadeInMs: 500,
  fadeOutMs: 1500
}
```

长 BGM 必须选择一个与成片等长的连续窗口；短 BGM 选择至少 500ms 的循环窗口。完整 `bgm` 返回值交给 `talking_head_apply`。渲染时 `pi-media` 会在循环接缝做短交叉淡化、在口播出现时 sidechain ducking，并在结尾淡出。波形只能展示振幅和结构，不能单独证明音乐情绪，仍需结合用户要求和素材信息判断。

### 7. 写入人工确认后的时间线

```js
talking_head_apply {
  projectId: "launch-video",
  expectedRevision: 1,
  aroll: [
    { id: "hook", sourceStartMs: 50, sourceEndMs: 4120 },
    { id: "answer", sourceStartMs: 4860, sourceEndMs: 13200 }
  ],
  broll: [selectionA.placement, selectionB.placement],
  bgm: selectedBgm.bgm,
  continuityPlanReceipt,
  editorialPlanReceipt
}
```

B-roll 使用成片时间轴定位，`assetStartMs` 来自联络表 manifest，并永远保留主口播音轨。`talking_head_apply` 会重新校验连续性规划凭证、选段哈希、素材与 manifest 的 SHA-256；规划区间、画面身份或时间点被手改、素材被替换都会拒绝写入新修订。同一素材 SHA-256 或跨文件重复画面身份也会被拒绝。若两个 B-roll 之间仍存在不超过 500ms 的 A-roll、观看连续组超过 8000ms，或结尾 A-roll 少于 3000ms，也会要求重新规划。当前不支持 B-roll 相互重叠，因为未定义 z-order。BGM 的素材、两份响度 manifest、波形图、起止时间、循环策略和混音参数也会被重新校验。

### 8. 交给 pi-media 渲染

完整表达计划通过后，用同一个源文件创建 `pi-media` 项目，再把上一步的 `mediaOperation` 原样传入；草稿只能使用明确标注的 `previewMediaOperation`：

```js
project_create { projectId: "launch-video-render", sourcePath: "raw/launch.mp4" }

edit_apply {
  projectId: "launch-video-render",
  expectedRevision: 1,
  operations: [mediaOperation]
}

render {
  projectId: "launch-video-render",
  revision: 2,
  outputPath: "out/launch-final.mp4"
}

review { path: "out/launch-final.mp4" }
```

新渲染器默认采用第一段主素材帧率，保存累计视频帧/音频样本对齐后的 `timelineTiming` 与正式渲染回执；字幕重映射应使用实际输出映射。`review.accepted` 是技术检查结果，不代表表达、接点或听感已通过。无法直接试听时必须列出成片接点并标为待听感验收。

## 工具

| 工具 | 作用 |
| --- | --- |
| `talking_head_create` | 从视频和词级转录建立 revision 1，用探测时长保护源文件边界并生成默认 EDL |
| `talking_head_get` | 读取指定 revision，分页返回整句与编辑候选，可选导出 pi-media EDL |
| `talking_head_editorial_plan` | 校验全片内容取舍、独有信息和输出区间，生成绑定凭证 |
| `talking_head_broll_plan` | 按主题规划 B-roll，保护有效 A-roll 窗口并报告覆盖率与待审阅跳点 |
| `talking_head_broll_match` | 只用文件名和目录名匹配本地 B-roll，返回受限 shortlist 与匹配凭证 |
| `talking_head_broll_select` | 验证匹配凭证和 pi-media 联络表 manifest 后生成 placement |
| `talking_head_bgm_plan` | 比较最终 A-roll 与完整 BGM 的 EBU R128 manifest，决定截取或循环 |
| `talking_head_bgm_select` | 验证波形图起止时间、淡入淡出和 BGM 选择凭证 |
| `talking_head_apply` | 写入新的不可变口播 revision，并返回 pi-media EDL |

## 当前边界

当前版本不负责语音转录、声学情绪识别、联网素材搜索、字幕、画面理解、音频解码或渲染。它提供整句文本、编辑计划及其凭证、时间轴、B-roll 选择凭证和 BGM 编排凭证；波形、EBU R128 检测、循环、ducking、混音和最终渲染仍由 `pi-media` 完成。
