# Video Distill

> 把教学类视频（YouTube / Bilibili / 本地文件）沉淀成结构化、带时间戳、可回查的 Obsidian 笔记与操作手册。只要用户提到把视频「整理成笔记 / 沉淀下来 / 记到 Obsidian / 写成操作手册 / 做成文档 / 蒸馏知识点 / 看完做个总结存起来」，或者丢来一个教程、课程、 技术分享、软件演示视频并希望留下可复用的产物，就使用本 skill —— 即使他们没说出 「笔记」这个词。产出是分层的三件套：带时间戳的主笔记、可脱离视频复现的 PLAYBOOK、 与视频原文严格分离的扩展阅读。 **判断只看一件事：用户要的是「落盘、可回查、能复现」的产物，还是一个答案。** 要答案 ⇒ 不用本 skill。 **以下情形一律不要用本 skill**（实测这些会被误触发，逐条排除）： - 问「这视频讲了啥 / 帮我总结一下 / 大概说说」—— 那是要答案，直接调 watch-skill CLI 回答 - 问某个片段、某一刻发生了什么、某个按钮是什么 —— 定点回答，不建笔记 - **会议录像出会议纪要 / 访谈整理 / 播客摘要** —— 本 skill 只做**教学类** （教程、课程、技术分享、软件演示）；会议纪要要的是决议与 owner，不是可复现步骤 - 视频**剪辑 / 压缩 / 转码 / 提取音频 / 字幕翻译 / 生成 srt** —— 那是 ffmpeg 的活 - 屏幕录像**排错定位**（「看看我这 bug 出在哪一步」）—— 那是调试，不是沉淀 - 把**文章 / PDF / 网页**整理成笔记 —— 本 skill 的输入必须是视频

- Skill: `vinsunfeng/video-distill` (Agent Skill, multi-file: 10 files)
- Install (CLI): `npx skillmds@latest add vinsunfeng/video-distill`
- Raw SKILL.md: https://api.skillmd.com/api/skills/vinsunfeng/video-distill/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Docs & Writing
- Author: vinsunfeng (https://skillmd.com/u/vinsunfeng)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/vinsunfeng/video-distill

---


# video-distill

## 第 0 步 · 先判断该不该走这套流程（30 秒，别跳过）

**问一句：用户要的是「一个答案」，还是「一份能回查、能复现的落盘产物」？**

要答案 ⇒ **立刻退出本流程**，直接用 watch-skill CLI 回答，例如：

```bash
watch-skill ask <engine_video_id> "<用户的问题>"     # 已索引过
watch-skill watch "<url>" --transcript-only        # 没索引过，先拿转录再回答
```

然后一句话告诉用户：「这个只要答案，我就不建笔记了；想留档我再走完整流程。」

### 为什么把这条放在正文第一句，而不是只靠 description

**因为它拦不住的是一部分 agent，而不是全部。** 这一节的措辞我改过两次 ——
第一版写「写在 description 里被实测证明拦不住」，**那句话是错的，只在 Hermes 上成立。**

2026-08-08 实测「这个视频讲的啥？我不想看完，你就跟我说个大概」：

| agent | 结果 | 判据 |
|---|---|---|
| **Claude**（Claude Code 2.1.226） | **正确不触发** | 抓 `stream-json`，`"name":"Skill"` **0 次**；同一台机器同一时刻的正例是 **2 次** |
| **Hermes + deepseek-v4-pro** | **误触发，重复 3 次 3/3** | 同轮正对照 3/3、负对照 0/3，仪器可靠 |

Hermes 那边的理由每次都类似：「video-distill 正是做 YouTube 摘要的」——
它看到「视频 → 结构化」就点着了，排除句压不过这个正信号。
而 Claude 那次转头调了 26 次 Bash 直接去回答 ——
**恰好就是上面这一节要求的做法**。

> **所以「排除项无效」是个只在特定 agent 上成立的结论，
> 而我第一版把它写成了普适的。**
> 一个只在一种环境里验过的结论，写成普适的那一刻就变成了错的。

⇒ description 的排除清单**要留着**（Claude 侧靠它就够了）；
而正文这个出口也**要留着** —— 它是给触发判断较弱的 agent 兜底的。
两者不是替代关系。

> 误触发的代价，因此从「白做一整套三件套」降到「多问一句话」。

**其余数字（Hermes 侧，20 条正负样例，`scripts/trigger_test.py`）**：

| description 版本 | 该触发 | 不该触发 |
|---|---|---|
| 末尾一句「不适用」 | 9/9 | 4/11 |
| 排除项列成 6 条清单 | 9/9 | 10/11 |

---

把教学视频变成「不看视频、只凭文档就能复现」的知识资产。

这是一份操作清单。每条规则背后都有源码核对和实测支撑，**看起来多余想精简某一条之前，
先读 `references/engine-internals.md` 对应小节** —— 有几条（索引清空、静默付费、
字幕轨误选）不遵守不会报错，只会安静地产出坏数据。

---

## 固化配置

> ⚠️ **换机器部署先本地化这两行**（2026-09-08 标注）：`WATCH_SKILL_BIN` 与 `VAULT`
> 是**本机**的绝对路径，别的机器必须改成自己的（`watch-skill` 的实际位置用
> `command -v watch-skill` 查；vault 目录不存在就先建，含 `视频笔记/` 子目录）。
> 后文所有命令只引用这里的变量——改这里即可，别处没有硬编码。

```bash
WATCH_SKILL_BIN=/Users/vdev/.local/bin/watch-skill   # 绝对路径：subagent 的 PATH 不保证含 ~/.local/bin
VAULT=/Users/vdev/notes                              # 已确认，支持 Bases
NOTES_ROOT="$VAULT/视频笔记"
```

**所有 watch-skill 调用用这个包装形式，不要精简：**

```bash
env -u ANTHROPIC_API_KEY -u OPENAI_API_KEY -u GEMINI_API_KEY -u OPENROUTER_API_KEY \
    WATCHSKILL_SUBTITLE_LANGS='zh.*' \
    WATCHSKILL_WHISPER_MODEL=large-v3-turbo \
    WATCHSKILL_CLOUD_STT_ENABLED=false \
    WATCHSKILL_COST_POLICY=offline_only \
    "$WATCH_SKILL_BIN" <subcommand> ...
```

| 项 | 不加会怎样 |
|---|---|
| `env -u ...KEY` | 带索引的 watch 会用 Haiku 描述最多 24 帧且**不检查 cost_policy**，有 key 就静默付费 |
| `SUBTITLE_LANGS='zh.*'`（不带 en） | 多数视频 info.json 的 `language` 为 None，字幕轨按字母序回落，`media.en.vtt` 会压过 `media.zh.vtt`，拿机翻轨当原文 |
| `WHISPER_MODEL` 显式指定 | 内存探测失败会落到 `base`，中文错字密集（「多参考图」→「多餐口圖」），不能用于笔记。`large-v3-turbo` 是 **Apple Silicon（mlx）实测档**；**非 CUDA 的 Linux/CPU 机器改 `medium`**（large-v3-turbo 在 CPU 未测，medium 实测简体准确但 RTF 0.62x），见 AGENT-START §3 |

英文视频临时改 `WATCHSKILL_SUBTITLE_LANGS='en.*'`。详见 references 第 2、3、5 节。

---

## 机械契约（写入前必读）

正文怎么组织、小节怎么起名、行文风格 —— **这些你自己判断，按内容实际结构来写更好**，
不要被模板的示例小节束缚。第一版实测就是这样：自创的 11 个小节比模板示例贴合得多。

但有 6 处是**字面契约**：`validate.py` 按精确形式 grep 它们，写法不同就过不了闸门。
它们的存在不是为了统一风格，是为了让「这份笔记可回查、来源可分辨」这件事**可机器验证**——
否则质量只能靠人逐份读。

| 契约 | 精确形式 | 为什么必须是这个形式 |
|---|---|---|
| 时间戳锚点 | **markdown 链接**：`[01:23](url&t=83s)`，区间也可以 `[02:38–02:51](url&t=158s)` | 可点击跳回原片是「这句话是讲者说的」的凭据。`〔02:38–02:51〕` 全角括号不是链接，点不动，等于没有凭据 |
| 主笔记锚点小节 | 必须有一个 `## 视频要点`，其下每条以时间戳链接开头 | 这是唯一被逐条校验的小节。**你可以自由增加任意其他小节**（背景、结构、坑…），只要这一节存在 |
| PLAYBOOK 步骤 | 每个 `### ` 步骤块内出现至少一个时间戳链接 | 操作手册最易错的是顺序和参数值，跳回原片是唯一纠错手段 |
| EXTEND 条目 | 每条以 `- ` 开头的条目里出现字面串 `[扩展]` | 这个标记是给机器读的。文件开头声明「本文件是扩展层」对人足够，但校验器只能逐条看。少了它，视频原文和补充内容在机器眼里无法区分 |
| `engine_video_id` | frontmatter 必填，16 位十六进制，且**必须等于 `sha256(source.strip())[:16]`** | 事后 `ask` / `search` 全靠它。校验器会用同一份 frontmatter 里的 `source` 重算并比对 —— 填个占位串过不了闸 |
| `content_hash` | frontmatter 必填 = **frontmatter 之后的正文，`.strip()` 后 sha256 十六进制前 16 位** | 重跑保护靠它判断文件是否被人工改过。算法不一致就永远误报「被改过」，保护机制自我失效。只对正文取 hash，否则 hash 会自指 |

```python
# content_hash 的唯一正确算法
import hashlib
body = 文件内容[frontmatter 结束的 "---\n" 之后 :]
content_hash = hashlib.sha256(body.strip().encode("utf-8")).hexdigest()[:16]
```

阶段 3 跑完 `validate.py` 就能确认这 6 条，且**每条都是真检查而非存在性检查**：
`engine_video_id` 用 `source` 重算比对，`content_hash` 重算比对（不一致是 ERROR，
不是提醒 —— 刚写完就对不上只可能是算错了），时间戳锚定行首，`[扩展]` 与小节名逐条 grep。

**闸门不过就不算交付完成**，不要贴着错误清单说「内容质量很好」——内容好和可验证是两件事，
这份 skill 要的是两者都有。

---

## Preflight（会话内首次运行做一遍）

```bash
"$WATCH_SKILL_BIN" doctor
```

`python`(3.11.x) / `ffmpeg` / `yt-dlp` / `js-runtime`(deno，YouTube 需要) 必须 ok。
`memory: warn` 可忽略（doctor 自身探测的 cosmetic 问题）。失败就停下报告，不要降级硬跑。

---

## 阶段 0 · 预探测与计划（第一个确认点）

1. **元数据**：`yt-dlp --skip-download --dump-single-json` 取 title / uploader /
   duration / `language` / `subtitles` / `automatic_captions` / `chapters` / 源分辨率。
2. **查重**：用平台 video_id grep `$NOTES_ROOT` 下的 frontmatter。命中就问
   「更新 / 跳过 / 另存版本」，不要静默覆盖 —— 用户可能已经手工补充过内容。
3. **分类初判**：`ls "$NOTES_ROOT"` 枚举现有分类，从中选；要新建就问一次。
   `$NOTES_ROOT` 为空（首次使用）时没有可选项 —— 这时**直接提出一个分类名**放进阶段 0
   的那次确认里，不要因为「只能从现有里选」而卡住。这条规则的目的是防止分类无节制增殖，
   不是在空目录上制造死锁。
4. **清晰度预判**：源高于 720p 且含代码/界面演示 → 此处就告诉用户「引擎硬编码
   `height<=720` 且不接受 cookie，要看清代码请给本地高清文件」。等下载完才发现会白费一次下载。
5. **打包成一次确认**：字幕来源、分段方案、抽帧分辨率、预计耗时、是否启用 WebSearch 扩展。
   ≥20 分钟且有章节 → 给章节地图让用户选精看范围。
   转录耗时按 RTF 0.05x 估（Apple Silicon mlx 实测值；CPU `medium` 实测 0.62x，
   约 12 倍，按本机档位换算）；不要报「转录费用」，本地转录免费。

---

## 阶段 1 · 全片转录（要不要索引，在这里定）

**先说实测事实（2026-08-28 源码核对 + 实测）**：引擎只有拿到 perception（抽帧+OCR）
才会写索引 —— CLI 源码是 `if index and result.perception is not None`。
**`--transcript-only` 恒无 perception ⇒ 这条路径上 `--index/--no-index` 开关是死的，
加不加都不写索引**（实测：跑完 `list` / `search` 都找不到该视频）。
本节早期版本称「`--transcript-only` 是唯一索引写入点」——**那是错的**，
它基于「会写转录段落进索引」的错误认知。详见 references 第 1 节。

所以按内容类型二选一：

**路径 A · 口播 / 理论型（快）**：

```bash
env -u ... "$WATCH_SKILL_BIN" watch "<source>" --transcript-only --out-dir "$WORK"
```

只拿转录，不下载视频画面。**不写索引** —— 阶段 2.1 走指示语 + 章节边界降级定 cue。

**路径 B · 操作型 / 界面密集（语义定 cue 的前提）**：

```bash
env -u ... "$WATCH_SKILL_BIN" watch "<source>" --out-dir "$WORK"
```

（即：**不 带 `--transcript-only`**。）转录 + 场景帧 + OCR 文本 + 嵌入索引一次拿到；
阶段 2.1 的 `search` / `ask` 从此可用，且 OCR 行让英文界面词能直接命中
（2026-08-28 实测：真实索引上英文查询 0.71、中文词组 0.82–0.88）。
**代价**：全片下载 + 抽帧 + OCR（13.7 分钟视频数分钟 CPU）。
**纪律**：从此刻起到阶段 2 结束，任何再跑的 watch 一律加 `--no-index` ——
索引写入前会 `DELETE` 该 video_id 的全部派生行，第二次带索引 watch 会把第一次清空。

完成后：

- 核对字幕轨确实是原生语言（看 `media.*.vtt` 的语言后缀与内容）。不符就显式设
  `WATCHSKILL_SUBTITLE_LANGS` 重跑。不要用机翻英文轨当原文——它是翻译，不是讲者说的话。
- 转录存档到 `$WORK/transcript.md`（阶段 4 蒸馏的前提，清理 work dir 后不可恢复）。
- **取 `engine_video_id`**。路径 B 下 watch 输出里有 `**Indexed:** video_id` 一行，直接取用；
  路径 A 下这行**不存在**（没写索引就不打印，实测 grep 不到）。
  两条路径通用的方法是自算：

  ```bash
  python3 -c "import hashlib,sys;print(hashlib.sha256(sys.argv[1].strip().encode()).hexdigest()[:16])" "<source 原样字符串>"
  ```

  必须和你传给 watch 的 source 字符串**逐字节一致**（引擎就是 `sha256(source.strip())[:16]`，
  URL 少一个参数就是另一个 id）。路径 B 下可用 `"$WATCH_SKILL_BIN" list` 交叉核对；
  路径 A 下 `list` 里**不会有它**（不在索引里），别误判成「没跑成」。
  这个值是六条机械契约之一：不落盘、work dir 一清，事后 `ask` / `search` 就只能重跑整条流水线。

---

## 阶段 2 · cue 定位 + 定点抽帧 + 分段理解

### 2.1 生成 cue 时间戳表

**前提：索引存在。** 只有阶段 1 走了路径 B 才有索引。不确定就先 `"$WATCH_SKILL_BIN" list`
看有没有该视频；没有而内容又确实需要语义定 cue（界面演示、节点操作）⇒ 回去补一次
阶段 1 路径 B 的全片 watch —— 此时补是**安全的**（路径 A 从没写过索引，没有派生行可被
DELETE 清掉，下载还有缓存）；纯口播内容直接降级指示语，别为 cue 白跑全片抽帧。
索引不可用时降级为纯指示语匹配，记入 `degradations`。

索引在，就对它做语义检索，命中的 hits 自带时间戳：

```bash
env -u ... "$WATCH_SKILL_BIN" search "操作步骤 点击设置 菜单路径"
env -u ... "$WATCH_SKILL_BIN" ask <engine_video_id> "代码示例 终端命令 报错信息"
```

**中文查询写成「空格分隔的、3 字以上词组」**：

```
✗ "如何配置代理服务器超时"
✓ "代理服务器 超时配置 网络设置"
```

引擎的 `_fts_query` 和 `lexical_anchor` 都按空白切分，且后者丢弃长度 < 3 的词。
中文整句会退化成一条要求逐字相邻的短语查（几乎零命中），同时把置信度锚点压成 0，
导致升级阶梯过度触发、白耗算力。所以双字词并成四字：`超时配置`、`环境变量`、`常见错误`。
详见 references 第 4 节。

**同一语义也试一遍英文词组。** 实测同一视频上英文查询命中率 **0.72 vs 中文 0.34** ——
原因不在模型偏好，在于索引里有大量英文：界面标签、节点名、参数名、文件名，以及不少
教学视频带的中英双语硬字幕（OCR 会把两条都读进索引）。嵌入模型本身是跨语言的
（实测中↔英余弦 0.59），所以 `LoRA loader node`、`resolution selector`、`API key setup`
这类查询往往比中文词组更能命中界面演示的时刻。**两种都发一遍，合并 hits。**

辅以转录里的指示语（「你看这里 / 打开设置 / 输入这个 / 如图 / 注意」）与章节边界。
索引不可用时降级为纯指示语匹配，记入 `degradations`。

**读输出前先过滤日志噪音**，否则帧路径和 OCR 文本会被冲没：

```bash
... | grep -v -e E5RT -e '^objc\[' -e RapidOCR
```

三个来源分别是 CoreML EP（本地补丁 2 的副作用）、cv2 与 av 各带一份 libavdevice、
以及 RapidOCR 对空白帧的例行报告。都不是错误。

### 2.2 分段观看

分段判据是**信息密度，不是时长**。实测一个 13.7 分钟的界面演示视频抽出 88 帧、
83 个场景切换 —— 按时长它「不用分段」，按实际负载它必须分。看这几个信号：

- 阶段 1 的 watch 报告里场景数 / 帧数（>60 帧就该分）
- cue 表的规模（cue 多到超过 `--max-frames` 就必须分）
- 内容形态：界面演示、逐节点讲解、代码走查 → 密；口播、幻灯片朗读 → 疏

分段就按 10~12 分钟切，每段派一个 subagent；密度低且总时长短才单遍处理。

```bash
env -u ... "$WATCH_SKILL_BIN" watch "<source>" \
  --start <t0> --end <t1> \
  --timestamps <该段 cue，逗号分隔> \
  --resolution 1280 --max-frames 60 --no-index --out-dir "$WORK"
```

- **必须加 `--no-index`**（理由同阶段 1）。
- **cue 数 ≤ `--max-frames`**：引擎对 cues 做 `_even_sample(cues, cap)`，超量会被抽稀，
  等于白定位，而且不报错。cue 多就提高 max-frames 或缩短分段。
> ### ⚠️ 你可能读不了图 —— 先确认，再决定怎么读帧
>
> 下面写的 `Read` 每个帧路径，**前提是你能直接接收图片输入**。
> **别按 agent 名字假设能力，按配置实测**——同一个 Hermes，配没配视觉是两个物种：
> 未配视觉时跑完 29 分钟教程产出满分四件套而 `assets/` 0 张帧（2026-08-08），
> 或自述「没有视觉模型」只靠 OCR（2026-09-08）；配了 `auxiliary.vision` 后直接读帧，
> 面板/字幕分层、连字幕残影都识别，质量经主代理亲读对答案验证（2026-09-08）。
>
> | 你的情况 | 怎么读帧 |
> |---|---|
> | 能直接吃图（Claude；或配了 `auxiliary.vision` 的 Hermes） | `Read` 帧路径 / 直接读图 |
> | Hermes 未配视觉 | **先配再跑**：profile config.yaml 加 `auxiliary.vision`（做法与实测可用的模型名见仓库 `HERMES-INSTALL.md` §〇——`deepseek-v4-flash-vision-exp` 可用，`deepseek-v4-vision-exp` API 不认） |
> | 不便改配置时的绕路 | 视觉 MCP，如 `mcp__minimax__understand_image`（已实测：逐字读出画面三行文字、认对颜色与形状） |
> | 真的什么都没有 | **必须写进 frontmatter 的 `degradations`**，并且不要产出操作型 PLAYBOOK —— 纯字幕的步骤不可复现（2026-09-08 一次纯 OCR 路线的观察：GUI 步骤可走但整体未达盲测验收，n=1，规则维持不变） |
>
> **「能读图」的判据是仪器检查，不是自述**：拿一张内容已知的帧考自己
> （比如已验收笔记里的证据帧），读得出版面结构和已知文字才算配通——
> hermes 自己报「能/不能」都作不了数（2026-09-08 两个方向都实测过）。
>
> **抽帧本身不需要任何 key** —— OCR 是本地 RapidOCR。
> 缺 key 只影响引擎的「场景描述」（`scene descriptions skipped (vision.no_api_key)`），
> 而**画面文字照样读得到**。别把「没有视觉模型」误当成「不能抽帧」。

- subagent 内 `Read` 每个帧路径（帧只进子代理上下文，用完即弃），返回**纯文本段落笔记**
  加该段证据帧路径。prompt 带上前一段的 running summary（术语表 + 进行中的主题），
  否则段间指代会断。
- 段落笔记**立刻落盘** `$WORK/.drafts/segment-N.md`，注明覆盖的时间范围。
- 证据帧**同步** `cp` 到 vault 的 `assets/`（命名 `mm-ss-描述.jpg`），不要攒到最后——
  会和临时目录清理产生竞态。
- 每段完成给用户一行进度。

**断点续跑**：开工前先看 `$WORK/.drafts/` 有哪几段，只补缺失的。

### 2.3 转写规则

| 画面类型 | 转写成 |
|---|---|
| 图表 | 数据表或 Mermaid，保留轴、单位、数值、结论 |
| 操作演示 | 可复现步骤：菜单路径、点击对象、输入值、预期反馈 |
| 代码/终端 | 完整文本 |
| 讲解/字幕 | 时间戳对齐的要点 |

> ### ⚠️ 命令与版本约束：**逐字复制，并把画面原文一起留下**
>
> 2026-08-08 实测的一次真实错误。画面上是：
>
> ```
> pip install -U "triton-windows>=3.7,<3.8"
> ```
>
> 而 agent 写进 PLAYBOOK 的是 `-U "triton-windows<3.7"` ——
> **丢掉 `>=3.7,` 之后，约束整个反了**：照它装会拿到 3.7 **以下**的旧版本。
> 而正确答案就在**它自己引用的那张帧**里。
>
> **占位符是诚实的空白；抄错的命令是自信的错误。后者更糟** ——
> 占位符会让人去查，错命令让人在别处排错。
>
> 所以两条硬规则：
>
> 1. **命令、版本号、参数值一律逐字复制，不要重述、不要"简化"。**
>    版本约束尤其危险：少一个边界就是另一个意思。
> 2. **把画面原文以引用块留在紧邻位置**：
>
>    ```markdown
>    > 画面原文（[15:55] 帧，逐字）：
>    > `pip install -U "triton-windows>=3.7,<3.8"`
>    > `Collecting triton-windows<3.8,>=3.7`
>    ```
>
>    **引用块的覆盖范围必须不小于正文的断言。** 2026-08-08 第二次盲测
>    在这个机制里抓到了我自己的错：正文写
>    `python.exe -m pip install -U "triton-windows>=3.7,<3.8"`，
>    而引用块只从 `pip install` 起抄 —— **「用哪个解释器装」那一点没有画面支撑**，
>    可正文看起来整条都有。同样，正文断言「无报错」时，
>    引用块就得抄到 `Successfully installed ...` 那一行。
>
>    > **一个覆盖不全的引用块，比没有引用块更容易骗人** ——
>    > 它把「部分有据」包装成「整条有据」。
>
>    这样**抄错能被发现** —— 而 `validate.py` **查不出这类错**：
>    它能校验结构、时间戳、hash，**不能校验你有没有读对画面**。
>    留下原文，是把不可自动检查的东西变成可人工核对的。

**专有名词、命令、参数值以 OCR 为准。** 这是实测结论：同一批帧上 OCR 正确读出
「多参考图」「真人 AI 短剧」，而 whisper 写成「多餐口圖」「真人短距」。换成
large-v3-turbo 后同音词「短距/短剧」依然错 —— 声学模型解决不了同音，画面文字才是
专名的可靠来源。

- OCR 有该词就用 OCR 的写法，冲突处标 `[OCR 与转录不一致 @mm:ss]`；
- OCR 未覆盖才采信转录；
- 两者都不可辨 → 走免模型回补：`ask <engine_video_id> "<画面文字 具体内容>"`，
  引擎会自动 `dense_resample`（高分辨率密集重抽 + OCR）→ `crop_and_reocr`（按 OCR box
  裁剪 2× 放大重读），两步都不调模型，恢复的证据还会 merge 回索引。

  **对它的正确预期**：它经常回答「视频没有清楚显示」，而这**就是它的价值** ——
  把「我读不出来」升级成「已确认视频里确实没有」。前者是你的失误，后者是一条有据可查的
  边界，可以放心写进「未覆盖 / 存疑」。别指望它总能变出答案，指望它帮你区分这两种情况。
- 仍不可辨才写 `[画面文字不可读 @mm:ss]`。不要猜测补全 —— 一个编造的参数值会让整份
  手册失去可信度。

OCR 自己也会错（「拆解」→「折解」、「剧本」→「刷本」），两者是互补关系。有疑问时
以你自己读帧所见为最终裁决。

**低清源（源视频 ≤480p，帧常只有 512x288）的四条实测纪律**（2026-09-08 hermes 跑 360p 源
中文双语硬字幕视频时发现，经主代理亲读帧复核与独立盲测确认）：

1. **OCR 会把画面 UI 文字与硬字幕（尤其中英双语硬字幕）混在同一行输出**——
   引用块必须标注「含硬字幕层」，且要能区分哪些 token 是界面文字（菜单、路径、数值）、
   哪些是字幕（整句口播）。否则读者会把口播/字幕当界面原文，盲测直接报「一段单帧 OCR
   不可能同时以逐字身份含两种来源」。
2. **同一画面多次扫描（普通抽帧 + `ask` 触发的 dense_resample）结果冲突时，
   多数一致的那组优先**；单次出现的异常 token（例：目录名 `models/unet` 只出现一次，
   而三列复扫都读 `models/Diffusion model`）按误读写进正文，但保留为「若…再试」的
   备选说明，别把话说死。另注意 OCR 会吞下划线：`diffusion_models` 常读成
   `Diffusion model`，正文给可执行目录名时要补回下划线并说明依据。
3. **PLAYBOOK 引用块里的帧时间戳必须落在该步骤的区间内**（validate.py 查不出
   这条）——盲测实测抓到把 [02:55] 的启动器帧引文放进标称 [02:16–02:26] 的步骤 1：
   内容对得上主题、对不上步骤，手册的「可回查」承诺当场破功。
4. **源分辨率要先查 formats 再定预期**：帧分辨率可能远低于引擎的 720p 上限
   （360p 源 → 512x288 帧），抽帧前看 `yt-dlp --dump-single-json` 的 formats，
   最高只有 360p 就提前告诉自己要密集复扫小字，别等帧出来才发现糊。

---

### 2.4 盲测（操作型必做，不是可选的质量加分）

写完 PLAYBOOK **就做这一步**，不要留到最后。派一个 subagent，只给它 PLAYBOOK
（不给转录、不给帧），让它复述操作并列出卡住的地方。

实测这一步在一份看起来完整的手册里揪出 3 处硬伤，包括「验证」小节里一条基于算术错误的
诊断公式（`10×24+1=241`，而实际 `frame_count` 是 65），会把读者引向去改 fps ——
正是手册本身明令禁止的操作。这类错误你自己读不出来，因为你知道视频里是怎么做的；
盲测代理不知道，所以它会卡住，而卡住的地方就是缺口。

缺口回补优先 `ask <engine_video_id> "<缺口词组>"`；`ask` 解决不了才定点重抽。

**完成判据**：盲测代理能一路走到最后一步，剩下的疑问全部落在「未覆盖 / 存疑」小节里。

---

## 阶段 2.4 · 可复现盲测（操作型必做）

> 质量控制那节写着「见阶段 2.4」，而**这一节此前根本不存在** ——
> 一个被列为必做、却从没被定义过的步骤。2026-08-08 补。

```bash
python3 <skill_dir>/scripts/blind_prep.py "<笔记目录>" video-distill-workspace/blind
# 隔离出一份看不到答案的副本（只留 PLAYBOOK.md、移除截图嵌入），并打印标准提示词
# 隔离目标别放 /tmp —— §固化配置外的纪律：中间产物放 /tmp 会被系统清掉（2026-08-08 丢过整轮数据）
```

把隔离目录交给一个**干净的 subagent**，用脚本打印的提示词。
**必须禁止它上网、禁止它读隔离目录以外的文件** ——
**一个能偷看的盲测，测的是偷看能力。**

### 首次实测的威力（对象是一份 `validate.py` 打满分的手册）

| 盲测报出 | 我的复核 |
|---|---|
| Triton 版本约束「像是编的」 | ✅ **正是我故意种进去的已知错误** ⇒ 仪器灵敏度过关 |
| 步骤 12/13 时间区间重叠、拼不成时间线 | ✅ 真的重叠 **124 秒**，已落成检查 |
| 「根目录**不是** python_embeded」与后面三步互相打脸 | ✅ 真的自相矛盾 |
| 装 xformers 再卸载会动到 torch，手册零提示 | ✅ 真实风险 |
| 手册里残留一个空代码块 | ✅ **是我修笔记时留下的垃圾** |
| 结论：**不能**独立完成，硬停在步骤 11 | 缺 workflow 来源 / 模型文件名 / 访问地址 |

它一次报出 **21 条缺信息 + 18 条可疑**。

> **`validate.py` 满分 ≠ 手册可用。**
> 它查结构、时间戳、hash；**查不出你有没有读对画面、有没有把话说全**。

### 两条纪律

1. **盲测发现的东西，凡是能机械判定的就落成 `validate.py` 检查。**
   盲测昂贵（要派一个 agent），不该让人在同一个坑上发现第二次。
2. **盲测报的「可疑」要逐条复核，不要照单全收。** 它也会错 ——
   但它错的成本是你多查一次，而漏报的成本是一份坏手册出厂。

## 阶段 2.5 · 类型判定

| type | 判据 | 产出 |
|---|---|---|
| 操作型 | 有可复现的软件/工具操作 | 产出 PLAYBOOK |
| 理论型 | 只讲原理、观点、方法论 | **不产出 PLAYBOOK**，改为「要点卡 + 自测题」并入主笔记 |
| 混合 | 兼有 | PLAYBOOK 只覆盖实际演示的部分 |

不要为了凑齐三件套而编造操作步骤 —— validate.py 会检查 type 与实际产出一致。

短视频（<10 分钟且知识点少）允许三层合并为单文件，不建目录三件套。

---

## 阶段 3 · 写入 Obsidian（第二个确认点）

### 目录与命名

```
$NOTES_ROOT/<分类>/<slug>/
├── <清洗后标题>.md      # 主笔记，入口
├── PLAYBOOK.md          # 条件产出
├── EXTEND.md
├── transcript.md
└── assets/              # mm-ss-描述.jpg
```

- `slug`：`yt-<视频ID>` / `bili-<BV号>` / `local-<文件名hash>`（可读，与引擎 id 不同）
- 文件名清洗：替换 `/ \ : # ^ [ ] |`，上限 80 字符，不含 emoji
- 互链用**完整路径 wikilink**：`[[视频笔记/编程开发/yt-xxx/PLAYBOOK|操作手册]]` ——
  各视频目录下 PLAYBOOK/EXTEND 同名，短链接会指向错的文件
- 时间戳锚点：YouTube `&t=<秒>s`；Bilibili `?t=<秒>`（多 P 加 `p=N`）；本地文件降级为
  纯文本 `[mm:ss]`

### 步骤

1. 基于实际内容最终确认分类与标题（与阶段 0 初判不符就在此修正），连同 slug、
   是否覆盖一并确认。这是最后一个计划内确认点。
2. 用 `templates/` 三件套填空，合并各段草稿，检查段间术语与编号一致，拷入 `transcript.md`。
3. EXTEND 默认精简模式（自身知识 + 官方文档链接）；WebSearch 仅在阶段 0 勾选时启用。
   扩展内容一律标 `[扩展]`，且不带时间戳 —— 时间戳是「视频里说过」的凭据，混进扩展层
   就分不清哪些话是讲者说的了。
4. 语言：正文中文，术语/命令/代码保留原文，首次出现给中译；非中文视频的关键论断附原文引述。
5. **重跑保护**：写入时把正文 hash 存进 frontmatter `content_hash`。重跑时重算，
   不一致即视为被人工改过 → 写 `*.regen.md` 列出差异交用户裁决，不要直接覆盖。
   （不要用 mtime 判断，Obsidian 插件会改 mtime。）
6. **运行校验并贴出结果**：
   ```bash
   python3 <skill_dir>/scripts/validate.py "<笔记目录>"
   ```
   有 ERROR 就修到通过再交付；WARN 逐条说明为何可接受。
7. 逐项自报 checklist 完成状态。

---

## 阶段 4 · 可选蒸馏（默认不执行，征询用户）

处理完成后由用户决定。分流按阶段 2.5 的类型判定：**操作内容走 PLAYBOOK 固化，方法论内容
走 cangjie**——两条路线不混用，也不必都跑（cangjie 与本 skill 是上下游关系，各自独立迭代，
经 transcript 契约衔接，勿把它的流程并入本文档）。

- **方法论型 / 混合型的方法论部分** → cangjie-skill 蒸馏成技能包。交接契约
  （2026-09-08 对全新视频全流程实测，记录见仓库 `docs/experiments/cangjie-stage4-2026-09-08.md`）：
  1. 输入只有 `transcript.md` **原始转录**，不是笔记 —— cangjie 的 V1 验证要求「原文至少
     2 处独立佐证」，笔记是二次压缩产物：两处「佐证」可能是同一时刻的两次转述（V1 假阳性），
     笔记的遗漏会被当成原文的完整（覆盖率门失效）。时间戳转录正好充当能力卡
     `source_evidence` 的定位凭据。
  2. 随转录交三样元信息：**标题 + 作者 + 发布日期**（目录命名与审计用）。
  3. 环境契约：cangjie 脚本依赖 PyYAML 且文档未声明——系统 python 下连 `doctor` 都起不来；
     本机用 watch-skill venv 的 python（自带 yaml）执行 `scripts/cangjie.py`。
  4. 编译产物装机到 `~/.claude/skills/<name>/`（Hermes 则带 category 层）。编译器硬闸门
     会拦断链产物；`also_read` 建议**写 slug**（capability_id 形态在未打补丁的机器上
     会被拦——上游文档与实现契约不一致，本机已在 clone 的 `local-patches` 分支修复，
     双形态兼容，待报上游 issue/PR）。
  5. 装机后必补触发测试（`scripts/trigger_test.py`，双对照纪律不变）——
     这是整条「视频 → skill」链路目前唯一未闭环的一环。
- **操作型** → PLAYBOOK 固化为项目内 skill（写 SKILL.md frontmatter + 带排除清单的触发
  描述，装机后同样补触发测试。此路线尚无实测记录，首个试点可用任一现有 PLAYBOOK）。

**清理时序**：证据帧已拷 assets、transcript 已存档、用户无追加问题 → 才允许清理
`$WORK`。引擎自己的下载缓存由 LRU 管理，不要手动删。

---

## 质量控制

1. **证据规则**：每条视频知识点必须有语音或画面证据并附时间戳；辨认不清必须标注。
2. **可复现盲测**：见阶段 2.4 —— 操作型视频的必做步骤，不在这里重复。
3. **降级透明**：任何降级写进 frontmatter `degradations` 与文首信息块 —— 字幕缺失走
   whisper、纯视觉、源被降采样到 720p、跳段、OCR 关闭、索引不可用。

---

## 错误处理

| 场景 | 处理 |
|---|---|
| 拿到机翻英文轨 | 核对 info.json `language`，重跑并显式指定原生语种 |
| 完全无字幕 | 本地 mlx whisper（无 key、无体积上限）。这是常态而非异常 |
| 源 >720p 且含代码/界面 | 阶段 0 就要本地高清文件。**没有 cookie 方案，引擎硬性不支持** |
| 需登录 / 地区限制 | 提示提供本地文件，不绕过 |
| 帧文字不可读 | OCR 交叉校验 → `ask` 免模型回补 → 定点重抽 → 仍不可读则标注 |
| 会话中断 | `.drafts/` 断点续跑，只补缺失段 |
| mlx 权重缺失 | 回退 `WATCHSKILL_WHISPER_MODEL=medium WATCHSKILL_WHISPER_BACKEND=ctranslate2`，记入 degradations |
| 引擎异常 | 记录复现命令；必要时按 README 的退路切回 claude-video |

---

## 典型调用

```
用 video-distill 把这个视频沉淀成笔记：https://www.bilibili.com/video/BVxxxx
```

阶段 0 确认一次 → 阶段 1 全片转录（13 分钟视频约 40 秒）→ 阶段 2 cue 定位 + 定点抽帧
→ 阶段 3 确认一次后写入 + 校验。

计划内确认 2 次；查重命中、需新建分类、需本地高清文件会各追加一次，最坏约 5 次。

---

## 附带资源

- `templates/NOTES.md` · `templates/PLAYBOOK.md` · `templates/EXTEND.md` — 产出模板，填空用
- `scripts/validate.py` — 分层校验，阶段 3 必跑
- `scripts/blind_prep.py` — 盲测隔离器（阶段 2.4），只留 PLAYBOOK 并打印标准提示词
- `scripts/trigger_test.py` — 触发准确性（阶段 4 装机后必跑，强制已知对照）
- `evals/trigger-evals.json`（9 正 / 11 负）· `evals/evals.json` — 触发与执行评测样例
- `references/engine-internals.md` — 引擎内部行为与实测数据。想改动上面任何一条规则前先读它

