Metadata-Version: 2.4
Name: podcast-maker-ldxs
Version: 1.0.0
Summary: podcast-maker — AI Agent
Home-page: https://github.com/Ldxs001/maby_agent
Author: Ldxs (wUwproject)
Author-email: wuwofc@yeah.net
Project-URL: GitHub, https://github.com/Ldxs001/maby_agent
Project-URL: Gitee, https://gitee.com/wUwproject/maby_agent
Project-URL: Documentation, https://github.com/Ldxs001/maby_agent#readme
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
License-File: NOTICE
Requires-Dist: edge-tts==7.2.8
Requires-Dist: Pillow==12.3.0
Dynamic: author
Dynamic: author-email
Dynamic: classifier
Dynamic: description
Dynamic: description-content-type
Dynamic: home-page
Dynamic: license-file
Dynamic: project-url
Dynamic: requires-dist
Dynamic: requires-python
Dynamic: summary

# Podcast Maker

播客制作智能体。素材进，成品出：**脚本 → 声音 → 字幕 → 画面 → 产物校验**，一条链走完。

定位很明确：播客的重点在听，不在看。所以视频侧只提供固定档位（背景、封面、动画、说话人指示、BGM），
够用、稳定、可复现，不追花哨。真正花力气的地方是脚本、时长、字幕和产物校验。

---

## 一、它解决什么

上一代播客工具留下的七个问题，这一版逐条堵死。

| 编号 | 现象 | 根因 | 本版的修法 |
|------|------|------|-----------|
| 1 | 设了 192 kbps，导出实测只有 160 kbps | 采样率 22050 Hz 属 MPEG-2 LSF，码率上限 160 kbps，编码器静默钳制 | 内置采样率-码率上限表，越界在编码前直接报错 |
| 2 | 字幕在词中间断开（`Claude-3\|.5`） | 逐字符判定西文 token，`-` 与 `.` 不匹配 | 改为正则区间判定，token 整体不可切 |
| 3 | 音画漂移数秒 | 用 `-shortest` 让 ffmpeg 自己决定长度 | 显式传 `-t <音频时长>`，并对齐断言 |
| 4 | 调语速后音调变尖 | 用采样点插值做变速 | 统一走 `atempo`，变速不变调 |
| 5 | 字幕不显示中文 | 把字体文件路径填进 ASS 的 Fontname | 传字体族名 + `fontsdir`，并用真实族名 |
| 6 | 重跑覆盖上一集产物 | 目录名用序号，已存在就往下写 | 时间戳 + 标题 slug，已存在即报错 |
| 7 | 视频里看不出谁在说话 | 无说话人指示 | ASS 矢量绘图层，当前说话方高亮 |

---

## 二、功能

按制作的次序列全。每一项都标出它落在哪 —— 目录结构见「九、目录结构」。

| 工序 | 能做什么 | 产物 / 落点 |
|------|----------|-------------|
| **项目** | 立项（三种规划方式之一，选定即定死）；节目设定：节目名、副标题、受众、音色、称呼、期号；项目列表与进度；归档（下线，可恢复）与删除（点两下确认，无回收站）；未归属产物认领 | 项目目录十个子文件夹、`_projects.json` |
| **素材** | 导入 `md` / `txt` / `docx` 或粘贴正文；按 `#` 锚点切章，标题行原样保留；入库即探查（层级归一、同系列归纳、附属页单列）；逐单元凝缩——保逻辑的有损压缩，带着行号落盘 | `素材/`：`index.json` 与每份 `<sid>.md` |
| **期数地图** | 五步排图：读结构 → 定凝缩单元层级 → 逐单元凝缩 → 模型合并与切分（**分出几组就是几期**）→ 按落点取原文；压比体检与整体重排；产能预检；插入分支（`3a` / `3b` 期号，同一锚点连续插入续用字母） | `地图/` |
| **脚本** | 成稿规划走分段生成（逻辑拆分 → 过桶 → 规划段主旨 → 逐段生成并核账）；逐期即兴与单集走整篇生成；逐句估时；形式门禁 12 条；内容检（承诺链必检、语义检可开关）；定点修补——只把点名的句子交给模型改；片头尾在整期定稿那一刻逐字粘上 | `脚本/<期号>.json`（含逐句实测时长） |
| **声音** | 本地 Qwen3-TTS（按需搭建，音色档案跟着项目走，同一句话的波形可复现）或备选 Edge-TTS；语气指令按素材类型的 `emotion_level` 档位下发 | `音视频/<期号>.mp3` / `.aac` |
| **字幕** | 四份同源、同次生成：`srt`（播放器）、`lrc`（音频平台歌词位）、`txt`（整秒）、`clean.txt`（整秒去掉方括号）；断行均衡、不在词中间断开；两档版式（歌词纵滚 / 单行横滚） | `字幕/` |
| **画面** | 背景与封面共用同一个纵向流式排版器；四档动画（静止 / 缓推 / 波形 / 频谱）；说话人指示；横竖两版 | `音视频/<期号>.mp4`；背景落 `背景/`，封面落 `封面/` |
| **产物校验** | 九步编排；产物阶段门禁 8 条；本期被要求产出什么由 manifest 定（关掉视频就不再要求视频）；未过写 `blocked.lock.json` 留痕，产物照落、照常记账 | `报告/<期号>.manifest.json`、`报告/<期号>.report.json` |
| **运行环境** | 探测 `ffmpeg` / `ffprobe`；缺失时给一键安装与自装指引；已就绪时显示实际路径、来源与版本 | `bin/`（不进仓库） |
| **界面与后台** | 四个标签页与配置页；批任务（串行、单期失败跳过、连续三期同一原因熔断、软中止）；实时日志与进度；生成走后台任务，中途刷新或关页面不丢 | — |

界面上的**任何取值范围都由后端下发**，前端不硬编码选项。地址栏 hash 就是路由
（`#project` `#script` `#render` `#config`），可以把某一页直接发给别人。

---

## 三、安装

需要 **Python 3.11 或更新**（`py -3.11` 或 PATH 上那个 `python` 都认），以及
[ffmpeg](https://ffmpeg.org/)（**要含 `ffprobe`**）。它**不随本仓分发**：
启动后到**配置页**最上面那张「运行环境」卡里会探测，缺了可以点**一键安装**
（从国内镜像下到项目 `bin/`，约十几秒，装完当场可用、不用重启）；
你也可以自己装，把解出来的 `bin` 目录放进 PATH 或项目 `bin/` 都认。然后：

```bash
python -m pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple/
```

（Windows 双击 `setup.bat` 会自动做这一步并起服务。）

| 依赖 | 用途 | 许可证 |
|------|------|--------|
| edge-tts | 备选语音引擎（调微软 Edge 的在线语音接口） | LGPL-3.0 |
| Pillow | 背景图与封面绘制 | HPND |
| ffmpeg / ffprobe | 拼接、混音、编码、合成 | LGPL/GPL |

主程序的依赖只有这两个包，其余全部走标准库。本地语音服务那套（torch 等，约 4.8 GB）
**不进主程序依赖**，它待在自己的环境里，见下。

下载源统一写在 `tools/sources.py`（PyPI 用清华，内有阿里云 / 腾讯云 / 中科大备选；
PyTorch 的 CUDA 轮子用上海交大，备选官方源）。脚本与依赖清单里各抄了一份，
`tests/test_sources.py` 盯着几处不许走散。

两条语音路的版权状况不同，差别不在「本地还是云端」：

- **Qwen3-TTS**（默认）：模型权重与代码均为 **Apache-2.0**（阿里云通义千问团队），
  推理全程在本机，不经过任何第三方服务。音色**跟着项目走**：每个项目立项后在
  合成开工前自动录一段 5～8 秒的参考音频（用模型自带的九个合成音色之一当基准，
  不是真人录音；文案固定为**陈述+疑问+惊叹三句型混合**——Base 克隆会整条继承
  ref 的韵律先验，混合文案让输出全局韵律更生动，工具自带念全校验防残废音频），
  落在项目的「音色」目录里，整期所有句子都以它为音色源头；
  换项目互不影响，要与别的项目一致就把那份目录整个复制过来。用 Base 变体 +
  ICL 而不是内置音色表，是因为句间音色漂移小一半以上（F0 相对极差 46.9% → 22.5%）。
  它那套依赖（PyTorch 等，约 4.8 GB）不进主程序依赖，也不同住一个环境——**按需搭建**：
  配置页把「语音引擎」选成 Qwen3-TTS，那张卡上就会出现环境状态与一个「搭建」按钮，
  点一次即可，环境 / 依赖 / 模型都由程序自己装，不需要你预装任何 Python 包。
  语气也走这条路：脚本每句的 `emotion` 标签会折成语气指令交给它（只认真情绪，
  语篇功能标签不转——「用过渡的语气说」那种指令对模型是纯噪音）。**给不给语气、
  给到什么程度，由素材类型的范式卡决定**（`emotion_level`）：论述类、教程、论文这些
  「不写心情」的文体，合成侧整句不发情绪指令——脚本里即便出现「平静」这类词也不发
  （它在那一档是被当作「不加修饰」的代码字填进来的，不是心情）；小说、散文这类「只到略」的
  文体，拼的是「用略带X的语气说」。实测里「明显/非常」两档方向会反转（同一个词在
  肯定句上抬高起伏、在中性句上又压低），所以不进产品。档位只有一个来源——写脚本
  能填什么、合成拼什么、约束解码的枚举里有几个标签，取的是同一个值；风格倾向里
  「情绪密度」那一项也按它发挥：不写心情的文体只谈功能标签怎么分布，允许略情绪才谈
  起伏多大。同一句话的波形
  是确定的：种子按 `(文本, 音色标识)` 派生（克隆时标识是参考音频的内容指纹），
  出了问题能照原样复现。
  之后不用管它：合成开工前先探一次，没在线就地拉起来，整期（整批）跑完自动停掉、
  把显存还回去；已经在跑的服务一律不动。细节见 `tts_service/README.md`。
- **Edge-TTS**（备选）：`edge-tts` 这个 Python 客户端库是 **LGPL-3.0**；但它合成的
  声音来自**微软 Edge 浏览器的在线朗读接口**——那不是微软公开授权、发放凭据的 API，
  而是浏览器自用通道，条款与可用性都由微软单方面决定；产出的音频同样受微软服务条款约束。
  想要权属清晰的声音，用本地那条。

设置页如实显示服务状态；起不来时把原因写进设置页与任务日志，不假装可用。
探活分的是**环境没建**、**依赖没装齐**、**模型没下**、**只是没在跑**四种状态——
前三样的动作都在配置页那张卡上（点「搭建」），第四样什么都不用做，合成时会自动拉起。
这四句措辞互不相同，因为把「该去下模型」说成「该去装包」就是让人白忙一场。

素材只支持 **md / txt / docx**。PDF 不支持：它是出版格式而非记录格式，章节层级
是从源文档投影出来的，要拿回来只能反推，而反推就是猜；而且它的文本顺序是排版
顺序（双栏、页边注、页眉页脚都按坐标给），不是阅读顺序。上传 PDF 会明确提示
「请提供源文件」。

Windows 下可直接双击 `setup.bat`：杀残留进程 → 检查依赖 → 起服务。
（或者直接双击 `_run.bat` 跳过检查。）

---

## 四、启动

```bash
python main.py                 # 默认 http://127.0.0.1:8811
python main.py --port 8812     # 指定端口
python main.py --check         # 只测 LLM 后端连通性
python main.py --voices        # 列出可用音色
python main.py --fonts         # 列出可用中文字体
python main.py --continue <树根> --episode 3   # 从锁文件恢复续跑第 3 期
```

`--continue` 收的是**树根**（项目目录，或单集的目录），期号由 `--episode` 给。省略期号
时从锁文件反推，但只有「恰好一期卡在门禁上」才自动定，其余一律把选择权交回来——
猜错了就是拿着另一期的产物当这一期的接着做，而这种错在产物上看不出来。

界面是深色顶栏加白画布的单页，四个标签页按制作顺序排列：

| 标签页 | 管什么 |
|--------|--------|
| 项目 | 立项（含规划方式）、项目列表与进度、成稿入库、期数地图与插入、期号、未归属产物认领 |
| 脚本 | 归属项目、要生成哪几期、素材导入、锚点切章、风格预设、目标时长、A/B 称呼与语速、生成与门禁结果、把改过的脚本存回本期 |
| 合成 | 归属项目、要合成哪几期（只列有脚本的期）、本期标题（改，不是填）、出片开关、实时日志、产物校验报告 |
| 配置 | 全部可调项，按命名空间自动分成十六张卡片（顺序即工序：写作目标、门禁阈值、片头片尾、项目、语言模型、角色与音色、音频输出、背景音乐、AIGC 标识、画面输出、背景、动画、说话人提示、字幕、封面、字体）；列表最上面另有一张独立的「运行环境」卡，管 ffmpeg 的探测与安装 |

脚本与合成两页只摆最常调的几项，其余全部可调项在「配置」页。
声音与画面不是独立标签，它们是脚本与合成的子配置——摆成并列标签会让人以为
那是一个并列的工序，而不是两页各自的参数。

所有控件的取值范围都由后端下发，前端不硬编码任何选项。地址栏 hash 就是路由
（`#project` `#script` `#render` `#config`），可以直接把某一页发给别人。

### 项目与期数

一档节目一个项目。**立项就是建项目目录**——此后素材、地图、脚本、成品各归其位，
都在这个项目的子文件夹里。项目记住节目名、副标题、受众、风格倾向、音色、称呼与期号，出片时自动往后排：

| 场景 | 行为 |
|------|------|
| 计划期数留空 | 由**期数地图**定：按凝缩与文体做合并与切分，**分出几组就是几期** |
| 计划期数填了 | 以人为准，排图照这个数排（超出「地图期数上限」时报错） |
| 压缩档 | 「写作目标」里选 1:3 / 1:5 / 1:10（默认 1:5）：一期装多少**原文**按档位折算，排图照此核对每期分量——口径：1 = 成稿目标（每期时长 × 标准语速，不含偏移），x = 1 × 1.25 × 档位；上限 = 档位 × 1.25，下限 = 1.25 × 1.2（25 分钟一期约 9900 字原文；不足就写不满，只能靠复读凑字数），破线回炉重排 |
| 脚本页选了项目 | 期号自动取项目的「下一期」，生成的脚本直接挂在该项目下 |
| 出片完成 | 产物落了盘就**记入项目、下一期号自动进位**，门禁过没过不改变这件事（过没过写在报告里，历史列表上标出未过的期） |
| 同一项目再生成 | 自动续接到下一期，不必手填期号 |
| 节目名 / 副标题 / 受众 | 整档节目共用一份，卡片上改一次，往后各期都跟着变：节目名是画面上最大的那行，受众印在片头那句「面向…的听众」里。受众在**排地图**时由模型给第一版，之后以你填的为准（重排地图不覆盖已填的） |
| 只出一集 | 照样立项——规划方式选「单集」，不排图、出完即完结。**没有第二条路可走** |
| 项目设定 vs 全局配置 | 项目优先，且只作用于本次运行，不回写全局 |

节目名、副标题与受众**不出现在配置页**——它们是项目设定，不是全局默认值。改的地方是项目
卡片上的「节目名 / 副标题 / 受众」。前两项改完立刻反映到画面；**受众改的是片头句，要重出
那一期才会变**（已经落盘的脚本里那句话是粘好的文字，不回溯）。

项目目录长这样（十个子文件夹在立项那一刻一次建齐）：

```text
<输出目录>/
  _projects.json                 登记表：只存身份与设定
  20260912-121051/               目录名即项目 id
    素材/   地图/   脚本/   封面/   背景/
    音视频/ 字幕/   图文/   报告/   过程/
```

**未归属的产物**仍单独列出，**不自动并入任何项目**：归属是人决定的，猜错了比不猜
更糟。这一栏是历史账——磁盘上早先躺着的不归属产物得有人认领；界面上已经不会再产生
新的这类目录（只出一集也是一个项目）。

项目下线有两条路，分得很清，也可以叠加（先归档、再删除）：

| 动作 | 做什么 | 产物 |
|------|--------|------|
| 归档 | 只翻一个标记，项目从各处列表里下线 | 全部保留，随时「恢复」 |
| 删除 | 登记表条目拔掉，项目目录（素材、地图、脚本、各期成品）整个删掉 | 不留 |

删除要点两下：第一下只是把按钮点亮（六秒没下文自动熄灭），第二下才动手。删掉的是
一整个目录，没有回收站，所以不提供「一键完成」的省事。

### 一次做多期

脚本与合成是两个页签，就是两步，不是一步。脚本可以批量生成，改好某一期再合上，
然后批量合成——中间随时可以停下来改某一期。

脚本页与合成页的「选期」都是下拉里带勾选，收起时只显示已选几期，不占地方：

| 页签 | 可选的期 | 批量时的行为 |
|------|----------|--------------|
| 脚本 | 该项目的全部期（含还没脚本的） | 串行生成，单期失败跳过 |
| 合成 | **只列有脚本的期** | 读盘上定稿那一份，串行出片，单期失败跳过 |

合成页不列没脚本的期，是为了让「选了一期却没脚本」这个状态不会产生——门禁在入口，
不做事后补救。批量跑的时候：

- **串行。** 语音合成与视频渲染吃满机器，并着跑只会互相拖慢。
- **单期失败跳过**，失败的期号与原因记下来，重勾一次就能补。
- **连续三期同一个原因失败就停下**，后面几期不再空跑（模型后端挂了、合成服务没起这类
  系统性问题，跳过等于把同一个错误重复 N 遍）。
- **中止是软的**：点「中止」后当前这一期做完就停，不硬杀线程——硬杀会把正写着的文件
  截断，半截的清单比没有更坏。


### 规划方式：立项时定死

立项必须选一种，选定之后不可更改——中途换模式会让已排的地图失去来处。

| 方式 | 适用 | 行为 |
|------|------|------|
| 成稿规划 | 手上有成稿（一本书、一部报题集），要一次排完 | 成稿入库 → 排期数地图 → 按图逐期出片，每期素材由落点自动取。**排图之前不进脚本页与合成页的可选集** |
| 逐期即兴 | 每期临时定讲什么 | 每期自己给素材，期号只往后走；立了项就能选 |
| 单集 | 只出一集 | 不排地图，素材当场给，出完即完结（不再有「下一期」）。同样是一个项目：有节目名、有封面、产物落在自己目录 |

三种方式回答的是同一个问题——**本期素材从哪来、要不要排图**。只出一集也走这条路：
从前它是「不选项目」，产物落进一个没人认领的目录、画面印不出节目名；收编之后流程
只剩一条：立项 → 脚本 → 合成。

本功能上线前立的项目读作「未定（旧项目）」，在项目卡片上补选一次即可，
补选之后同样定死。

受众（片头里「面向…的听众」那一句）是跟着地图来的，所以**成稿规划**有它，逐期即兴与单集
没有——留空就整段消失，片头退化成「欢迎收听《节目名》。」，其余部分不受影响；要填就上项目
卡片填一次，全档节目共用。

### 成稿与期数地图

成稿落在项目的 `素材/` 下：一份 `index.json` 记索引，每份素材一个 `<sid>.md`。画地图分五步，**一次长调用变成一批小调用**：

**三件长活都在后台跑**：素材入库即探查、排图、插入建议——全是「模型慢慢算」的活（探查几分钟、排图几十分钟），点了按钮弹窗里就有实时进度与逐条日志（凝缩到第几节一目了然），弹窗可以收起、任务照跑，跑完自动重开面板。同一项目这些活互相排他（它们写的是同一批探查文件），别的项目不受影响。

1. **结构从稿子里读出来**（`#`、`一、`、Word 标题样式），不喂模型。层级归一、同系列
   归纳、附录挑出来单列，每条记下它的**起止行**与**所属章节链**——都是可查的事实。
2. **定凝缩的单元层级**：按这批素材的**文体**判断哪一级才算一个完整的表达单元（方法论看
   论证链、小说看情节单元、采访看话题板块……判据写在范式卡里）。这一步定的是颗粒：选
   篇一级，凝缩 39 次；选章一级，就是 179 次。判据不是"标题看起来像不像一个完整话题"
   ——越具体的标题越像，照着猜只会把层级判细、把单元切碎。
3. **逐单元凝缩**：一次调用只看一个单元的原文，做的是**保逻辑的有损压缩**——留下它说清楚了
   什么、反对什么、有哪几条主线，以及跟着每条主线的细节区分。几十万字的原文进不了
   上下文，几十条凝缩可以——每次的上下文与输出都不随全书体量增长。按哪种文体压，由
   范式卡里的 `condense` 一项给出（与排图用的"重点判据"是两个字段，见下）。
   **凝缩带着位置落盘**：压的是原文的哪一块（第几行到第几行）、属于哪一篇，由取料那一步
   实测的范围回填，不是另算一遍。
4. **模型合并与切分**：拿一份凝缩清单（每条带行号范围、所属章节与字数）回答"哪些节合起来
   是一期、这一期叫什么、讲什么"。**分出几组就是几期**——期数由内容结构定，不由字数除。
   它不抄标题、不编期号、不算字数，这些都由代码做，所以也就没有"抄错一条落点就取不到料"
   这件事。分完做**压比体检**——每期原文量对照压缩档核对，两个方向各有红线：超过上限
   （成稿目标 × 1.25 × 档位）是一期塞不下，低于下限（成稿目标 × 1.25 × 1.2）是料不够——写不满时模型
   只能把写过的段落再背一遍凑数。把问题清单喂回去**整体重排**（凝缩复用，只是重新分组，
   最多重试 2 次）；重排仍超上限就报错停下，仍低于下限则落警告交人。
5. **按落点取原文**：生成某一期时取回该期涵盖的**单元原文**（含子节），不截断——
   照地图上标的行号切，切走的与凝缩时看的是同一块。

两级之间只有「章节标题 + 行号」这一个标识，所以 md 上传时标题行会原样保留：`#` 是结构锚点，
不是装饰，剥掉之后按章切分就无从谈起。地图长这样：

| 期号 | 标题 | 主旨 | 要点 | 素材落点 |
|------|------|------|------|----------|
| 1 | 链与两头 | 判断的两头与中间的链 | 确定性交代码；解释空间给模型 | 书稿.md · 第一章 链与两头 |
| 2 | 代码编期号 | 期号错位看不出来 | 模型编期号会错位且看不出来 | 书稿.md · 第二章 代码编期号 |

**期号与落点一律由代码填。** 模型只给分组、标题、主旨、要点。落点是「素材 + 标题 +
行号」——同一本书里两节都叫「小结」是常态，只按标题取会取到靠前那一条，而地图上写的
是同一串字，错了也看不出来。

**期数不是算出来的。** 早先的做法是：总字数除「一期容量（目标时长 × 语速）」得期数，
再把「共 N 期，请正好排出 N 期」写进提示词。素材按逻辑需要 100 期时，那等于命令模型把
超出来的内容无声塞进别的期里，每一期都超载。现在**分组的结果就是期数**；「地图期数
上限」是排完之后的红线，超了报错要求合并，不做排前截断。排图之前倒有一道**产能预检**：
每期连下限（素材不足成稿目标 × 1.25 × 1.2）都摊不到的，当场报错拦下，不烧一次注定失败的排图；
素材远多于期数能消化的，警告期数偏少，不拦。

**漏掉的单元会被补回来。** 模型给的分组经 `_clean_groups()` 归一成一个不重不漏的划分：
越界与重复的序号丢弃、分组间顺序打结时按首个序号重排、**没被任何一期提到的单元补成
独立一期**。漏掉的不是"少讲一点"，是那几节从此不在任何一期里，而地图看上去完整。

**凝缩拿不到就停下。** 凝缩结果是分组的依据，没有它排出来的图是按字数硬切的——那种图
看着完整，才是最难的假成功。

**凝缩要留住"判断合并所需的东西"。** 排图那一步**只看凝缩、不看原文**，所以凝缩里没有
的信息，排图永远看不到：两节讲同一条论证链的不同环节，凝缩里都写成"讲了 X 的重要性"，
模型就看不出它们该合。因此凝缩不设长度上限与条数上限——40 字装不下一条带条件与反例的
主干，而"取前 6 条"那道砍在清单里也要撤掉。

**插入与排图是同一条管线，只是容器换了。** 画地图是在空根容器里建 1、2、3……，插入是
选定锚点期做父容器，在它下面建 3a、3b……。所以插入走的也是那套「逐单元凝缩 → 序号
分组 → 落点由代码映射 → 压比体检」：新素材的每一节先单独凝缩（已凝缩过的节直接用缓存），
模型只判断插在哪一期之后、把序号分组成期，落点由程序按分到的单元直接取用——抄错落点
这类错从根上不存在了。

### 插入分支

项目定了 60 期之后又发现另一本书可作佐证，可以把它插进现有计划。**插入面板自带上传区**：
要插的那份还没入库，就地传（md / txt / docx 或粘贴正文），入库并探查完自动勾上它，
不必退到「成稿」面板传完再回来。已经排进地图的素材会标出「已在地图中」并默认不打勾，
不勾任何素材时后端也只处理还没排进地图的——同一份素材插两遍，期号会产出 3a、3b 而
内容与既有各期重复。模型对照带主旨的已有计划简报定插入点并在锚点下分组建期，
看过、改过再落库：

```
1、2、3、3a、4、…、21、21a、21b、21c、…、60
```

分支期号由后端编（`branch_no()`），锚点不变、同一锚点再插一批续用后面的字母
（`3a 3b` → `3c 3d`），超过 26 个进位成双字母（`3z` → `3aa`）。规则只有这一处，
界面与出片阶段都不再各编一套。落库位置固定在锚点分支链的末尾：先插的批排在前面，
后插的接着往下排，不按字母序乱插。有地图的项目，「下一期」由地图决定——分支期号夹在主期
之间，靠数字进位算不出来（`bump_episode("3a")` 会得到 `"4a"`，把分支当成了新主线）。

### 接 LLM

界面里选后端，模型名可以从本机可用模型里挑，也可以直接填一个列表里没有的名字：

```bash
python main.py --backend lm-studio --model <模型名>
python main.py --backend ollama --base-url http://127.0.0.1:11434 --model qwen3:8b
```

生成脚本必须走 LLM。**没有可用后端时直接报错中断，不会退化成模板填充。**

**推理型与普通模型都要能跑，这是客户端的责任。** 用哪种模型由使用者自己定，程序不替他
决定要不要思考——所以不会主动传"别思考"这类字段（只留一个透传口）。真跑了推理，日志里
带「思考 N token」，代价看得见。

后端之间的差异在这里处理：明确拒绝某个可选字段（HTTP 400/422）时**逐个摘掉**重试，摘一个
标一个，字段之间不连坐——次序是「辅助字段先摘、约束解码最后」，`response_format` 是三者里
最值钱的，不能因为后端不认一个 usage 开关就把它一起丢掉（那种情况下 `meta.usage_dropped`
标的是 usage，不是约束解码）。超时、5xx、返回非 JSON **不降级**——它们与字段无关，摘字段治
不了，当成"字段不认"只会在日志上写下一个假结论、把真实起因丢掉。思考与答案混在同一个
`content` 里的后端，会剥掉成对的思考段，找 JSON 时逐个花括号试解析并**取最后一个成立的**
（没带思考标记的后端是"先想后答"，第一个成功的多半是思考里的草稿）。

**超时判「卡死」，不判「慢」。** 对话一律走流式：后端吐一个字收一个字，收到就重置静默
计时。两道闸门分工不同——

| 配置项 | 判的是 | 默认 |
|--------|--------|------|
| `llm.idle_timeout` | 多久没有下一个字 → 判后端卡死 | 300 秒 |
| `llm.timeout`（总时限） | 整次生成最久允许多久 → 判写得太久 | 3600 秒 |

从前只有一个总时长上限，而且回复要整段等，于是"模型在慢慢写"和"后端已经死了"被同一个数
一起判死：写一期长稿必然撞墙，日志上却写着「后端响应超时」。本机实测（`0gm-1.0-35b-a3b`
冷加载 + system 1.7k + 素材 20.3k 字）**第一个字要等 57.7 秒**——预填充本身就要几十秒，
静默时限按最坏情况给，不是按"答一句话要多久"给。

流式中途断掉（没有 `[DONE]`、也没有 `finish_reason`）会直接报错并带上已收字数，不把半截
稿子当成品交给下游——否则错误会推迟到「JSON 无法解析」那一步，起因被埋在两个阶段之外。

---

## 五、怎么用

三种规划方式只在**素材从哪来**这一步分岔，脚本、合成、产物三段合流。
每一步的细节在各节里，这一节只给路径与按钮名。

### 5.1 走完一期（逐期即兴）

| 步 | 在哪 | 做什么 | 结果 |
|----|------|--------|------|
| 1 | 项目页 | 点「立项」，规划方式选「逐期即兴」，填节目名（副标题与受众可留空） | 项目目录一次建齐 |
| 2 | 脚本页 | 选归属项目（期号自动取「下一期」），导入本期素材，选风格预设与目标时长 | 素材落 `素材/` |
| 3 | 脚本页 | 点「生成脚本」 | 后台任务：弹窗里带实时日志与进度；先写正文，再内容检，最后形式门禁 |
| 4 | 脚本页 | 看门禁结果。有未过项就按点名改，改完点「存回本期」 | 定稿落 `脚本/<期号>.json` |
| 5 | 合成页 | 选归属项目与要合成的期（只列有脚本的期），确认本期标题，开关出片项，点「开始合成」 | 串行出片，单期失败跳过 |
| 6 | 合成页 | 看产物校验表，点「▶ 试听」 | 产物按类归位，报告落 `报告/` |

脚本阶段的未过项**不拦人**：稿子与问题一起落盘；点合成时再弹一次提醒，
逐条列出，点「仍然合成」才继续。

### 5.2 一次排完（成稿规划）

| 步 | 在哪 | 做什么 |
|----|------|--------|
| 1 | 项目页 | 立项选「成稿规划」。计划期数留空则由**地图**定，填了以人为准 |
| 2 | 项目页 → 成稿 | 传成稿（`md` / `txt` / `docx`）。入库即探查 |
| 3 | 项目页 | 点「排地图」（已有地图时是「重新排图」）。排完可在卡片上改标题、主旨、要点；动了要点就点「保存并重排地图」；只想改设定不重排，点「只保存（保留旧地图）」 |
| 4 | 脚本页 | 逐期或批量生成脚本。这一路走分段生成，按落点取原文 |
| 5 | 合成页 | 同 5.1 的第 5、6 步 |

**排图之前，这个项目不进脚本页与合成页的可选集**——期号还没定，选了也无从生成。

### 5.3 只出一集（单集）

立项选「单集」：不排地图、不设计划期数，素材当场给，出完即完结（不再有「下一期」）。
产物照样落在自己的项目目录里，画面也印得出节目名。

### 5.4 常见操作

| 想做的事 | 怎么做 |
|----------|--------|
| 改某一期再重合成 | 脚本页选该期 → 改 → 「存回本期」→ 合成页重新合成。合成读的是盘上定稿那一份 |
| 一期没跑完就断了 | `python main.py --continue <树根> --episode <期号>`。省略期号时，只有「恰好一期卡在门禁上」才自动定 |
| 中途停下 | 点「中止」：当前这一步做完就停，不硬杀线程——硬杀会把正写着的文件截断 |
| 重出一期 | 合成页重新勾该期，再点「开始合成」 |
| 换音色 | 配置页「角色与音色」卡改；项目卡片上还能按项目覆盖。稿子的时长目标不随音色变 |
| 语音环境没搭 | 配置页「角色与音色」卡上点「搭建本地语音环境」，环境、依赖、模型都由程序自己装 |
| 缺 ffmpeg | 配置页「运行环境」卡会报出来，点「一键安装」下到项目 `bin/`；也可以自己装，装完点「重新探测」 |
| 改节目名 / 副标题 / 受众 | 项目卡片上改。前两项立刻反映到画面；受众改的是片头句，要重出那一期才变 |
| 项目下线 | 项目卡片上「归档」（全部保留，可「恢复」）或「删除」（登记表条目与项目目录一并抹掉，点两下确认） |
| 查历史产物 | 历史列表点「报告」：文件链接、成片播放器与门禁表 |

### 5.5 配置页十六张卡片

配置页按命名空间自动分组，卡片顺序就是制作的顺序。

| 卡片 | 管什么 |
|------|--------|
| 写作目标 | 决定脚本写多长、写成什么调子 |
| 门禁阈值 | 不达标的脚本直接拦下，不进入合成 |
| 片头片尾 | 片头尾由结构写死，整期定稿那一刻粘上；模型只写正文 |
| 项目 | 品牌行、标语、版权行——每期画面上都印的那几行字 |
| 语言模型 | 脚本由本地或自建模型生成，此处指定用哪一个 |
| 角色与音色 | 两位主持人的称呼、音色与语速。音色随所选引擎切换——同屏只显示当前引擎那一套 |
| 音频输出 | 导出的音频文件参数；码率受采样率的物理上限约束 |
| 背景音乐 | 背景音乐的来源、音量与人声闪避 |
| AIGC 标识 | AI 生成内容的合规标识：元数据隐式标识与片头语音声明 |
| 画面输出 | 画幅、帧率与编码质量 |
| 背景 | 背景配色档位 |
| 动画 | 背景是否运动。播客重点不在画面，默认静止 |
| 说话人提示 | 画面如何区分当前说话人 |
| 字幕 | 字幕版式、字号与边距 |
| 封面 | 封面档位；封面上的字与背景共用同一份，取自项目与配置 |
| 字体 | 画面与字幕各用哪款字体。下拉里每款都按自己的字形渲染 |

列表最上面另有一张**独立的「运行环境」卡**（不归命名空间），管 ffmpeg 的探测与安装。
脚本页与合成页只摆最常调的几项，其余全部可调项都在这里。

### 5.6 出问题先看哪里

| 症状 | 先看 |
|------|------|
| 生成脚本报「没有可用后端」 | 配置页「语言模型」卡；`python main.py --check` 测连通性。生成脚本必须走 LLM，没有后端时直接报错中断，不会退化成模板填充 |
| 合成起不来，日志说语音服务有问题 | 配置页「角色与音色」卡把状态分成四种：环境没建、依赖没装齐、模型没下、只是没在跑。前三种都点「搭建本地语音环境」，第四种什么都不用做——合成时会自动拉起 |
| 一期出片了但校验不过 | 合成页的产物校验表，或 `报告/<期号>.report.json`。每一项都写明判据与实测值 |
| 界面点了没反应 | 先看服务还在不在（控制台窗口是否关掉）。生成与合成都是后台任务，关掉浏览器不影响任务，重新打开还能看到 |
| 字幕不显示中文 | 配置页「字体」卡换一款带中文字形的字体。缺字形时程序直接报错并点名是哪个字，不会画出方块 |
| 输出码率对不上 | 先核对采样率——码率上限由它决定，越界在编码前直接报错，不会静默钳制 |
| 稿子时长总差一截 | 「总时长偏差」是软门禁，只记账不拦人：真正的时长要等合成出来才算数 |

**日志与产物都在盘上。** 任务日志随任务落盘，产物按类归位，本期被要求产出什么、
实际产出了什么、当时的配置快照，都在这一期的 `manifest.json` 与 `report.json` 里。
界面关了、服务重启了，盘上那份都还在。

---

## 六、产物

产物按类归位到项目的子文件夹里，不摊在一个「集目录」中：

| 位置 | 文件 | 说明 |
|------|------|------|
| `脚本/` | `<期号>.json` | 脚本（含逐句实测时长）。落盘定稿那一份，**合成读的就是它** |
| `音视频/` | `<期号>.mp3` / `.aac` | 成片音频（按配置） |
| `音视频/` | `<期号>.mp4` | 横屏视频 |
| `音视频/` | `<期号>_v.mp4` | 竖屏视频（可关） |
| `字幕/` | `<期号>.srt` / `.lrc` / `.txt` / `<期号>_clean.txt` | 四份同一份源、同一次生成：`.srt` 给播放器，`.lrc` 给音频平台的歌词位，`.txt` 整秒，`_clean.txt` 是整秒去掉时间戳方括号的那一份。进片的 `.ass` 在 `过程/` 里拼 |
| `图文/` | `<期号>.md` | 对话体图文 |
| `背景/` | `<期号>_bg.png` / `_bg_v.png` | 横竖背景图 |
| `背景/` | `<期号>_bgm.wav` | 内置背景音乐 |
| `封面/` | `<期号>_cover_16x9\|3x4\|1x1.png` | 封面三尺寸 |
| `报告/` | `<期号>.manifest.json` | 本期被要求产出什么（含视频开关）、产物清单与配置快照 |
| `报告/` | `<期号>.report.json` | 校验报告 |
| `过程/` | `<期号>/` | 中间件；`blocked.lock.json` 门禁未通过时出现在这里 |

文件名前缀一律是期号（分支期号 `3a` 也不会与主期撞名）。

**各阶段各自落盘**：素材入库即落、地图排完即落、脚本生成即落、出片产物各自归位。
脚本要是不落盘，「先生成、改一改、再合成」这条路就走不通——脚本与合成会被绑成一步。

**落盘的那一份是成品：正文 + 片头尾。** 片头（「欢迎收听《节目名》，面向…的听众」「本期讲述
期标题，播讲人甲乙」）与片尾（「这里是《节目名》，欢迎关注。」）由程序按模板**逐字拼**出来，
在所有门禁、内容检与定点修补都做完之后、**整期定稿那一刻**才粘上去——模型、门禁、修补看到的
自始至终只有正文。这样两件事同时成立：门禁判的是模型真正写的那部分，而片子该有的固定标识
一句不少。

**标签序列（片头档位 × 回顾开关，四种）**：标准档＋回顾＝开场／回顾／承接／正文…／收束；
标准档＝开场／承接／正文…／收束；简档＋回顾＝开场／回顾／正文…／收束；简档＝开场／正文…／收束。
**开场只有一个**——片头第二句挂的是词表内的中性档「承接」（它是「接开场的话头往下讲本期」，
不是第二个开场）。**前期回顾固定在第 2 句**（紧跟片头第一句，不分档位）：它是「上期讲到哪儿」
的交代，得在开场之后紧接着让人听到，摆到正文后面就成尾声了。开场／回顾／收束是程序专用标签
（`PROGRAM_ONLY_TAGS`），只在**重判**与**脚本页标签下拉**两处出现——盘上稿子里有它们，下拉里
没有的话那一格就没有任何选项能被选中，浏览器会退回显示第一项。

**前期回顾是三条，不是一段。** 引用的料一物一句：上期标题、期主旨、前三段段主旨各占一句

```text
上期《{上一期的标题}》。
聊的是{上一期的期主旨}。
讲了{上一期的前三段段主旨}等。
```

三个引用值进位前只剃**尾部**的句读标点与空白——三条模板各自在句末补自己的句号，不剃尾会拼出
「。、」「。。」；头不剃，中间一个字不动。拼完**不再按字数切**：从前是把料拼成一整段（实测长到
195 / 241 字）再按正文那把尺切句，切点落在长度上，于是切出过「…收窄至"仅填空"的本质，」下一句以
「的架构迭代」开头这种半句话。句子边界现在由**结构**决定，最多三句。任一路引用值取不到（第 1 期、
本期不在地图上、上一期没留下段主旨）就**三条一句都不粘**——留「上期《》。」这种半句比少说难看。

**整期只粘一次。** 片头尾不进轮：它不参与生成、不过门禁、不被定点修补、不核字数——逐字拼出来
的东西，没有「模型照没照做」可验。所以：

- **中间产物一律是裸正文。** 分段生成时每段写完落的是半期正文；整期稿子在门禁那几轮里落的也是
  当时那一版正文。中途卡住、被中止、进程被杀，手上那份就如实是「写到这儿的正文」，不会伪装成
  一整期。
- **定稿那一次粘，且只粘一次。** 门禁过了定稿，轮次用尽也定稿——改成功没改成功，交到手上的那
  一份都带片头尾。这一份同时落盘（出片读的是盘上那份）与返回（界面拿的是返回值），两处共用
  同一个对象。从前是每轮落盘都粘、同一轮返回时又粘一遍，两轮跑下来日志冒三行「片头尾已粘上」，
  读的人分不清是粘重了还是落了几次盘；现在整期一行。

粘过的稿子形态像成品，混进中间产物里就再也分不出哪份写完、哪份没写完——所以粘合点要数得清：
**整期一次，粘在定稿那一刻**。

**是「粘」不是「替换」。** 从前那版拿片头句盖掉正文第 1 句、拿片尾句盖掉正文最后 1 句，
句数不变所以门禁看不出来，实际每期正文头尾各丢一句——第 2 句应答的那句、倒数第二句问的那句，
听感上就是「没错」开头、问题悬空。现在只往两头加，正文一句不动、一句不删。

片头里那句受众是**节目设定**（`audience`）：画地图时问一次——同一档节目各期必须逐字一致，
每期各生成一次必然漂移，那就不是节目的标识了——落进项目，在项目卡片上可改，人填过不覆盖。
单集与逐期即兴没有地图，那一小段自动消失（片头退化成「欢迎收听《节目名》。」），不留
「面向的听众」这种半句被 TTS 字正腔圆地念出来。

产物校验只要求「这一期被要求产出什么」。关掉视频开关后还去要视频，
每一次出片都会判不过；判不过就不占期号，项目进度永远停在原地，
而磁盘上每份产物看着都好好的。所以视频开关写进 `manifest.json`，
不适用的检查项直接不进报告，而不是留一行「不适用」占位。

---

## 七、门禁

共 20 条（生成阶段 12 条、产物阶段 8 条；其中 fail 16、warn 4，另有 soft 1），分 fail 与 warn 两级。

条目里的 `stage` 不是分类标签，是**执行权**：生成阶段的条目只在脚本阶段执行，产物阶段的
只在合成阶段执行，没有谁越界去替对方判一遍。产物阶段不过时写 `blocked.lock.json` **留痕**、
照常出片照常记账——产物与报告都落了盘，要不要带着问题出片由人定；过没过写在报告里，批量跑完
时未过的期单独列出来（不会只剩一片「成功」）。warn 在严格模式下同样判不过（同样是留痕、出片、
记账）。脚本阶段不过不拦人
——稿子和问题一起落盘，改稿还是直接出片由人定，合成前只弹一次提醒。此外还有一类 **soft**：判据本身是估算
值的那些（目前只有总时长偏差一条），不达标只在报告与界面上记「提示·不阻断」，既不拦放行、
也不回灌重写——口径本身只是一个通用语速，拿它把稿子打回去，模型只能围着一个它既测不出、
也控不住的秒数反复改。

严格模式默认开启：宁可标出来未过，不放过一个错项。产物阶段不过照样出片、照样记账，
过没过写在报告里、在历史列表上标出来——盘上有什么、账上是什么、报告里判的是什么，三份一致。

| 阶段 | 门禁 |
|------|------|
| 脚本生成·形式 | JSON 合法、字段完整、同一人连续句数（上限取自素材类型的范式卡）、句长区间、单句时长、情绪标签、情绪档位（不写心情的文体不许出现心情词）、禁用词、可朗读（不含 emoji、图标、不可见字符、网址、命令参数、排版符号） |
| 脚本生成·软项 | 总时长偏差（≤ 15%，只记账不拦人） |
| 脚本生成·内容 | 承诺链检（LLM，**必检**）；语义检（LLM，**配置开关、默认关**）。两段串行：形式门禁成环 → 内容检成环；不过就定点修补，检不出句号的记「未修好」 |
| 产物 | 音画时长差、实测码率、实测采样率、断词率、字体族名、背景水印、产物完整性、封面三尺寸 |

产物阶段的「产物完整性」与「音画时长差」只在要求视频时才成立；关掉视频开关后
这两项不适用，直接不进报告。要求一个从没被要求的东西，等于让每一期都判不过。

**片头尾没有门禁，因为它不在这份稿子里。** 它由程序在整期定稿那一刻逐字粘上（见「六、产物」），
模型从头到尾没参与，也就没有「它照没照做」可验。硬留一条判据只有两种下场：恒真（白占一格），
或者拿正文去验首末句、每轮报「未命中」，然后让模型去改一句它**从来没写过**的句子。
同理，语义检的判据范围就是呈上去的整份正文，不必再为片头尾单开豁免——判据范围与结构要求
冲突时，错的是判据。

**粘合句进不了逐句判据，是同一条理由的另一面。** 片头／前期回顾／片尾带着程序专用标签
（`PROGRAM_ONLY_TAGS`）出现在稿子里（**脚本页重判**读的就是盘上成品整份、含片头尾），门禁按标签
认出它们之后，句长、单句时长、禁用词、可朗读、句尾标点、语篇词表这六项一律跳过；连说账上它们
还当**分隔符**——前后两段正文各算各的，不因为它被算成「连着说」。**整篇量照算**（句数、总时长）：
粘合句确实在稿子里、确实要念，报告里注明跳过了几句。

**脚本的事归脚本，合成的事归合成。** 两个阶段各判各的、各落各的，合成端不碰脚本的账：

| | 脚本阶段 | 合成阶段 |
|---|---|---|
| 判什么 | 生成阶段那 12 条（形式项 + 总时长软项 + 内容检） | 产物阶段那 8 条 |
| 结论落点 | `过程/<期号>/gate.generate.json` | `报告/<期号>.report.json` |
| 不过怎么办 | **定点修补**：只把点名的句子交给模型改，全篇行数一个字不动；结构坏了才整篇重出；指不出句号的交人工。修满轮次仍未过就把这一版稿子连同结论一起落盘，阶段到此为止 | 写 `blocked.lock.json` **留痕**（`--continue` 靠它认期），产物落盘即出片、即记账；过没过写进报告，界面在历史列表标出未过的期 |
| 越界防线 | — | 不写稿（没稿子就报错，不现写一份）、不判稿（不拿生成阶段的条目再审一遍） |

两者之间只有一次**提醒**：点合成时读那份脚本阶段的结论，有未通过项就弹框逐条列出，
人点「仍然合成」才继续，并在清单里留一个 `script_issues_ack` 作凭证。提醒不是闸门——
不点确认只是不发请求，不是判它过了。框里写明这份记录产生于脚本生成那一刻，不追踪此后
的人工修改：改过没有以手上的稿子为准，**不为了把它算准而再跑一遍**。

总时长偏差留在生成阶段，但按软门禁办：这一阶段才看得见它，可它判的是估算值——真正的
时长要等合成出来才算数，口径本身只是一个通用语速。不达标只记账，不拿它打回重写。

**估时长用一把与音色无关的尺子。** 脚本阶段的口径是**标准语速** 4.39 有效字/秒（≈256
汉字/分钟，锚在国家语委《普通话水平测试实施纲要》的正常语速与朗读口径之间）。目标字数
反推、逐句估时、总时长门禁、容量反推都用它——稿子的时长目标不随「这一期换了哪个音色」变，
一份稿子在哪里估都是同一个数。音色之间快慢的差异换算成一个比例，挂在配置页每个音色下拉
框下面（音色语速 ÷ 标准语速：0.91 即比标准慢，1.00 即同速），没有实测的音色照实写
「尚无实测」，不拿 1.00 冒充量过的数。

**措辞禁忌为什么必须在提示词里先说。** 只在生成完之后扫描命中，模型事先不知道要避开
什么，命中与否全看运气，几轮重试就是几次抽签。所以词表连同「换成什么」一起前置进系统
提示词；回灌时再给到句——第几句、命中了哪个词、改用什么，并明确「只改这几句，其余照抄」。

**定点修补那一侧同样要前置。** 只把命中句与替换词回灌给修补模型是不够的：它改这一句时
会连带写进另一个同样说满的词——实测里模型补长一句就写进了「毫无」，回门禁当场被拦。
所以修补的系统提示词带的是同一份禁忌表（同一个渲染结果，不另存一份），回门禁那一道只当兜底。

**改一句不该重打一遍。** 第 1 轮整篇写；之后能定点就定点——只把门禁点名的句子交给模型，
输出是一份补丁（`{edits: [{index, text, emotion, absorb?}]}`），回来按句号原地替换。
行数默认一个字不动——这是这条路的根基：门禁报的「第 N 句」在补丁前后指的是同一行，
一旦允许增删，编号全漂，就得回头重编一遍。**唯一的例外是并句 `absorb`**：门禁点名
「同一人连着说超限」时，把这一段并进第一句（`absorb` 是并掉的句数），行数因此变少。

**并句的字数判据是一个恒非空的区间。** 从前判据要「并后 ≥ 原句合计 × 70%」，提示词又硬性
要求「每句 ≤ 40 字」——原句合计 > 57 字时两条同时满足是不可能的，而 70% 这条一个字都没进
提示词。真机上并句因此**一次没成功过**（当时本机 A 捧哏上限 1 句，「并成 1 句」正是最易无解的
情形，第 2 期 15 处全没修好；v0.38.0 起放宽为 2）。现在写成区间：常规 `[单句上限 × 70%, 单句上限]`（本机
**28~40**）；原句合计连 28 都不到时退成 `[原句合计 × 70%, 原句合计]`。区间**恒非空**，且
上限必然 ≤ 单句上限——并出来的句子不会再被句长门禁打回。判据与处方**调同一个函数**
（`absorb_span`），处方里直接写给模型「每句 28~40 字」这种具体数字，不是只给一句原则。

「不许凭空增删、不许换人」不靠提示词劝，靠补丁的结构里写不出来——没有「加一句」，
「删一句」只有 `absorb` 这一种表达且只给超限用，说话人根本不在 schema 里：把 A 的
一句问话翻给 B，就成了 B 自己问自己，那是拿格式代替语义（靠硬翻说话人的那个后处理
`enforce_max_run` 已在 v0.34.0 取下）。片头尾仍由 `glue_intro_outro` 在整期定稿
那一刻粘上，不进补丁。
另外，补丁按**句号**改句子，而句号只在正文那一份上算得准——所以循环里跑的一直是正文，
粘好的成品只出现在定稿那一刻（`generate._finish` 里的 `_glued_draft`），中间产物全是裸正文。

补丁只给被点名句与它前后各两句，**不给全篇**。这不是省字那么简单，是实测出来的：把全篇
220 句一起递过去，模型三次里有两次直接不吐 JSON（被上下文带跑，改写起解释性文字）；只给
窗口则三次全中。纯形式项（措辞、句长）不带素材，内容项（语义、承诺链）才带——改「编造」
得知道素材支持什么，改「太长」只跟这一句自己的字面有关。

**哪些问题能定点，只看它带不带句号。** 形式门禁全都自带（它本来就是逐句判的）；内容检要
模型自己给，给了才算。分成三类各有各的去处：带句号的定点改；结构坏了（JSON 不合法、字段
残缺）只能整篇重出；内容检自己指不出句号的——**不动稿子，交人工**。让模型为一句它自己都
说不清的问题重写两百句，只是重新摇一次骰子，还会把已经好的句子一起改坏。

**每条提示词都按块分类。** 一个推进点位牵扯的资料不止一种：要处理的正文、判断用的参考、
上一步的产物，以及这一步当前该产出什么。全堆在一起，模型只能靠语序猜哪块是依据。所以
每一块都自带名字与用途——块名用【】框起（【素材】【已有计划】【待审脚本】），括注说清它
怎么用（「判断依据」「当前任务：按这里逐条改」）。风格、背景这类与具体内容无关的块，在
括注里写明「以当前任务为准」：冲突时让位的必须是它们。

**贵的东西按需跑，便宜的东西每轮全跑。** 定点修补之后，形式门禁（走代码、毫秒级）每轮
全套重跑——改一句话的字数会牵动单句时长与总时长，改一个用词可能撞上新的禁用词，只重跑
被触发的那一项会放走改出来的新毛病。内容检（走模型、要读整篇）只重判**上一轮没过或没判过**
的那几项，已经判通过的那一项本轮不重喂，结论从上一轮带过来——不带走的话报告里会凭空少
一行，「这一项检查通过」的记录就丢了。

**每轮写完就落盘。** 生成一轮要十几分钟，从前等整段跑完才第一次写盘，中途卡住、被中止、
进程被起停脚本杀掉，手上就什么都没有——稿子明明已经写好了。现在每轮定型完就往出片时读的
那个位置写一份，落的是「当前最新的那一版」，与最终返回的那版一致。

**内容检为什么挂在脚本生成阶段。** 语义检（台词有没有编造素材之外的东西）与承诺链检
（开头开的那个口子收没收），判断依据都在语义层，词汇匹配做不了——对话体前后用词不同
本来就是正常的，拿重叠率去判是拿错了工具。所以这两项交给模型。

位置比判法更要紧：它接在脚本生成里，**不通过就带着检出的落点去定点修补**。等音频视频都
渲染完再检，片子已经定死，检出来也改不动。**脚本生成是三段串行**（v0.36.0 定序）：
**出货**（写正文，只求一份可用稿子；产物不可用按 `script.segment_parse_rounds` 重试）→
**检查**（内容检，修到过或修满 `script.check_rounds` 都往下走）→ **门禁**（形式门禁，
修到过或修满 `script.gate_rounds` 就落盘定稿）。修满仍不过不空转，带着问题交人。门禁排
最后是因为它每轮**重新判形式**、并把内容检的结论并回同一份报告——形式结论永远对着最终稿。
三段内部**没有重写**——重写只属于出货段，那是「写」的事，不是「判」和「改」的事；
补丁则是**逐条落地**：单条不合格只拒该条（原因递回下一轮提示词），其余照落，不像从前
一条坏就整份作废、一轮十几分钟作废。语义检要逐句核素材、装不下还得分批，是全链最贵的
一项，所以做成了**配置开关、默认关**（`script.check_semantic`）；承诺链检**必检**——
它不看素材、只看脚本自己，一次调用判完。

**内容检的输出必须带落点，两块形状不同。** 语义项是 `{line, quote, problem}`；承诺项是
`{line, promise, how, speaker, close_after}`——承诺提在哪一句、承诺是什么、怎样才算闭合、
这句闭合的话由谁来说、建议插在第几句之后。这不是加个字段，是定点修补的前置条件——
检查只说「整篇偏题」而不说哪一句，补丁就无处可下。

**落点不许是 0。** 契约把 `line` 收紧为「≥1 且不超总句数」，提示词也不再教模型填 0，
而要求它填其中最该改的那一句。模型仍给不出句号时，程序拿 `quote` 去稿子反查（去空白后
比对子串，不花模型调用）；反查不到就记一笔「未修好」，不早退、不重写。承诺项的修法是
**插入**：补丁新增 `inserts` 形态，在建议位置插一句带说话人的新话——旧契约不许增行，
逼得模型去删承诺句，问题在报告上消失、稿子一字未动。

**模型没给结论，不等于检查通过。** 模型漏返回某一项时，那一项记为「未判定」落进待复核，
不默认放行——把「没检查」记成「检查通过」，报告上的绿灯比不检更坏。
判定为不通过的才参与放行（语义检 fail 级、承诺链 warn 级）。

**审校的输出预算与写作同一条口径。** 取全局 `llm.max_tokens`，不另设一个小值：
推理型模型把思考过程也算进这份额度，给小了就是思考吃完、答案一个字不剩，
两项检查一同落成「未判定」。它同时是**输出上限**，服务端校验「输入 + 上限 ≤ 上下文」，
所以该值要落在模型实际加载的上下文之内——模型自身上限与它无关：
加载时给 8192，配置里写 48640，请求要么被拒（HTTP 400 上下文超限），
要么一路生成到塞满。

**能喂多少字由输入额度算出来，不由常数定。** 内容检与定点修补从前都把素材截到前 6000 字——
生产里一期素材约 2 万字，只喂进 30%；落在后面的依据在模型眼里就是「不存在」，语义检于是
把本来正确的句子判成编造，再交给定点修补把它改坏。

**三把尺各管各的，别用一个数兼两件事。** 「喂多少字」里面藏着两个不同的问题，从前混成
一个，于是两个不同单位的数被拿来做减法再比大小——一期 6.4 万字符的技术文档在「朗读字数」
那把尺下压比合格，到写作侧被判「超出容量」，原文一个字没进提示词：

| 层 | 回答什么 | 尺 | 不合格怎么办 |
|---|---|---|---|
| **画地图** | 这一期该讲多少料 | 压比 / 朗读字数（成稿目标 × 1.25 × 压缩档，与排图体检同一把尺） | 重排；两轮仍超则报错要求拆期 |
| **单次调用** | 这一次能装多少 | **token**：输入额度 = `llm.max_tokens` × `llm.input_ratio` | 调倍率，或回地图拆期（后端窗口由你自己开，程序不探也不管） |
| **划分段** | 这些料要分几次喂完 | 逻辑拆分（模型按内容分组，只回节序号）+ 装箱（纯算术，逐段过额度） | **按整节切，绝不丢原文**；单节自己就超容则报错要求调倍率 / 拆期 |

画地图那个数**可以往下透传，但只当换算用**——透传下去的是「本期成稿目标字数」，拿去摊
段配额；它**不参与**「这次装不装得下」的判断。倍率调大调小，地图一个字节不动；改时长改
档位，倍率也不用动。**后端窗口不归程序管**——够不够自己调，程序既不探也不提要求；
任务开始时日志只报这次准备喂多少（单次额度 = 最大输出 × 倍率、素材折成多少 token），
放不下就切段喂完，原文一字不丢。字符与 token 的折合比由后端回传的
`prompt_tokens` 反标（取中位数），不写死经验值——中文、数字、标点的折合比差得远；
没有样本时按 1 字 = 1 token 顶格算，**折算只准偏保守**。

**放不下的时候，切段而不是砍料。** 写作先由模型按内容把节分组成逻辑段，再让桶**逐个
逻辑段**过输入额度：装得下就是一段，装不下在组内按**整节**切成几段（见下一段）——
最终真正分别喂给模型的段叫**写作段**，一个逻辑段至少落成一个写作段，所以**写作段数 ≥
逻辑段数**（日志那行「N 个逻辑段 → M 个写作段」就是这两个数）；
段内若仍装不下（折算比有波动、或已写正文比配额长了一截），把那几节**按整节分批**分几次
喂完——不丢原文、也不换成凝缩。只有装箱覆盖不到的边角才退到有损降级：可用额度已
用光、或拿不到逐节原文时改用凝缩（每节逻辑骨架）顶替，连凝缩都没有（逐期即兴没排过图）
才按额度截原文；两者都是**有损的，日志与素材头部都写明**。内容检则**分批核**：每批能装
多少由输入额度折回来（`budget_chars_for_check`），按它切段、相邻批重叠 1000 字、每批带
整篇脚本（脚本不切片——句与素材段之间没有可靠映射，硬切只会制造新误判），合并规则只有
一句——同一句要在每一批里都被报出来才算真问题（某句的依据只落在某一批里，那一批当然
找不到它，撤销它才对；每一批都找不到，才等价于全文里找不到）。批数超过 8 批判「未核」
交人工，不把没核的那部分记成「核过且没问题」。

**成稿规划走分段生成，治字数漂移。** 整篇一次生成时模型没有全局计数器，实测同一管线
一期超目标 4 倍、另一期只写六成。成稿规划的项目有地图与各节凝缩，脚本这一步走三步：

1. **逻辑拆分**——模型按内容把节分组成段（哪几节讲的是同一件事），只回节序号；
   递给它的清单只给「节号 + 标题 + 凝缩」，**一个体量数字都不给**（分组看语义，摆着
   字数会把它往"按字数摊匀"上带）。分组过三道归一：越界/重复丢掉、按序号排序
   （**不许重排**，讲述顺序由地图定死）、漏掉的节补成独立一段。两次答不出合法分组
   就退回「一节一段」。
2. **逐段过桶**——桶逐个逻辑段过输入额度（装得下就是一段、装不下在组内按**整节**切成
   几段）；两个逻辑段之间是话题转折，绝不并进同一段。**切分单位只有整节凝缩**：单个节
   自己就超额度时报错要求调倍率或拆期，不再从节中间切——半个节取不出料、也算不出账。
3. **规划轮写段主旨**——给每段写一句**段主旨**（与期主旨同层级：这一段讲的是什么事），
   并给全篇起标题。段数钉死，不能增删段、也不许写"从哪里起到哪里止"。**这一轮同样
   不看字数**——段边界是程序定的、配额是程序摊的，字数摆进来只会把"这一段讲什么"
   带成"这一段该多长"。

然后逐段生成、逐段按字数核账（容差 = max(配额 × 15%, 地板 × 15%)，与总时长门禁同一把尺），
差额滚入下段配额——误差逐段吸收，总账咬住目标。每段只喂**本段那几节的原文**，不再把整期素材
过一遍闸门。超差**不重写整段、也不改已有句子**，只按字数增删（少了插句、多了压删），而这两条
路的**落地校验都不判句长**——句长是全篇一把尺的量，归门禁判、两个方向都有处方；写作阶段替它
把关，代价是把「一条坏句」放大成「整段停摆」（真机一条 7 字句让 40+ 条新增整批作废，那一段的
缺口 1872 字一轮都没再试）。**补丁落地失败也只作废那一轮**（稿子一字不动、轮次照减），不收工。

**每轮的顺序是「补字 → 查重 → 替换 → 计算」。** 第一轮只写作、轮末算一次账；之后每轮从
**上一轮末**的差额决定补字还是压字，补完立刻查重、就地替换，替换完再重新核账。「计算」挪到
轮末是本轮收口，差额供下一轮开头补字用。补字是复读的源头——第 2 期补出 33 句逐字复读，就是
因为补字轮**看不到本段以外的正文**却要它「挑正文还没讲到的点」；现在补字轮也带上**全篇已写
正文**（与首写轮视野对齐），补完马上查、当场换。

**查重判整篇，动刀只动本段。** 重复只能按句指认，而「段 2 复读段 1」这种只有**整篇范围**
看得见——所以 `find_repeats` 拿整篇句列表判、只报本段内后出现的副本；`apply_replace` 换成
新句时**行数一个字不变**（没有并句、没有插入），并且新句必须**不短于被替换那句、不长于单句
上限**——换短了核账又报缺口，下一轮又补、又可能复读，绕回老路。替换提示词把事情说清楚：问题
发生在本段、原文素材也只给本段，但**要看得到所有已写的**——【本段原文】与【已写脚本】（全篇
正文，逐句带编号）分开摆。

**段配额全是加法**：段配额 = 段内各节配额之和、段 token = 段内各节 token 之和、段原文 =
段内各节原文按序拼。从前那套「块配额 = 整节配额 × 块字符 / 整节字符」的除法连同它的误差
一起消失——配额一把尺、内容另一把尺，两边不同源时数字必然对不上。

配额摊完还有一道**地板**，但它只防一件事：提示词里那句「约 N 句」不许与配额矛盾——
「约 N 句」带一个软句数下限，配额低于「N 句 × 每句最少字」时，提示词就一边要模型写 15 句、
一边只给它不足 120 字的额度。所以地板取的是「软句数下限 × **每句最少字**」（本机 120），
**不是碎段合并那把「× 期望句长」（本机 360）的产能尺**——那把尺判的是「这一段的料够不够
单开一段」，拿它当地板等于把按料摊好的配额抬高三倍，同一期里这几段偷偷换了压比。

**段容差的下限就是这根地板的派生量**（本机 120 × 15% = 18 字）。从前它是一个独立常数（60），
没有算式出处、还让「各段容差之和 = 全篇容差」这笔账对不上；改用地板的派生量后，全篇各段容差
之和恰是 15% × 全篇配额（配额本身低于地板的段除外），账自洽。

**配额权重是「这一节的原文有效字」**（凝缩那一步算出的 `chars`）——**单位必须与成稿目标
同源**：成稿目标出自 `chars_for_target`（`时长 × 速度 × STANDARD_K`，`STANDARD_K` 的单位
是**有效字/秒**），地图压比也是拿期素材的有效字比成稿目标，三处同一把尺，配额的占比才和
压比可比。

三个数各有各的用途，别混：

| 量 | 单位 | 归谁 |
|---|---|---|
| 原文有效字（`chars`） | 有效字 | **配额权重**——这一节能写出多少稿 |
| 原始字符数（`loc.chars` / 取到的原文长度） | 字符 | **装箱折 token**——这一节占多少输入（排版符号与西文数字也在里面，它们念不成本） |
| 摘要自己的字数（gist + points） | 字符 | 与料无关，**已无消费者** |

用错尺的后果是同一件事：那一节被派了它写不出的字数，只能把刚写过的话换个说法再讲一遍。
第 2 期实测——按摘要字数摊，段 2 被多派 776 字、压比压到 1.50 贴死下限，去重删掉 64 句；
改按原始字符数摊仍不齐；**只有按有效字摊，两段压比才同时等于整期的 2.09**。

路线由**项目模式**定死，不是一个开关：成稿规划（mapped）走分段——有地图与凝缩，逐期
配额、防重复才有依据；逐期即兴与单集走整篇——当场给料、出完即止，没有凝缩可分。成稿
规划万一本期凝缩读不出来（还没排图、版本对不上），也退回整篇照常出稿，不让人卡在
「一步跑不动」上。

无论走哪条路，成稿最后都过一遍**程序硬去重**，但它是**最后兜底**，不是主力（v0.37.0）：
分段路现在靠「补字 → 查重 → 替换」当场把重复换掉，走到这一刀时**还剩重复**才删。
判据是归一化（去空白与标点）后完全同字、且 ≥ 12 字，措辞相近的拦不住——那是改写，得靠
提示词那三条纪律。删了会记一笔：**照实说**删了几句、本期压比多少、缺口多少字，不再推断
「这说明素材撑不满目标时长」——第 2 期料是目标的 2.09 倍，把责任推给素材方向是错的。

其余全部 fail-closed：不过就**照实报**，不伪造一份「看着对」的结果——产物阶段的门禁不过写
`blocked.lock.json` 留痕、照常出片照常记账（过没过写进报告，历史列表上标出来），脚本阶段的
门禁不过把稿子连同结论一起落盘。
门禁未过时，被拒的脚本与逐项明细会一并落盘（`script.rejected.json`、`gate.generate.json`），
避免只留一句「门禁未通过」而无从排查。

---

## 八、排版与画面

**排版是流式的，不是摆坐标。** 背景与封面共用同一个纵向流式执行器：每个元素的位置
由上一个元素的实测包围盒推出来，间距一律写成字号的倍数，集中在 `LAYOUT` 一张表里。

这条约束是有来历的。曾经用「主标题位置加一百一十八像素」表示标题下方框线的位置，
而字号一百零八的字实测高约一百三十——线正好横穿标题字。这类缺陷调数值调不好：
换字号会穿、换画幅会穿、换字体还是会穿。所以位置只能由实测包围盒推出，
间距只能取自规格表，序列里禁止就地写数值，这条有测试守着。

每个元素落下的实测区间会被记录，测试据此断言相邻元素不压叠、框线始终在主标题之下。

**最大的一行是节目名，不是期标题。** 自上而下的层级固定为：

```text
主标题 = 节目名（项目）   ← 最大
副标题（项目，可留空）
期标题（地图自动取）      ← 只在背景上
期数（项目进度）          ← 只在背景上
标语（全局配置，可留空）
版权行（全局配置）        ← 最小
```

播客卖的是系列品牌，不是这一期讲什么。期标题字数不定，长起来在最大档只能折行，
一期一个样，摆上去就散。字号表按角色命名（`hero` / `second` / `episode`）而不是
按位置，因为主标题换成节目名之后，`f_big` / `f_mid` 这种叫法没人分得清。

**画面上印哪几行，出处只有一张表。** `FRAME_LINES` 一行一项，写明它跟谁走、值从
哪来、印在哪张画面上：

| 行 | 跟谁 | 封面 | 背景 |
|---|---|---|---|
| 品牌行 | 全局配置 | 印 | 印 |
| 主标题（节目名） | 项目 | 印 | 印 |
| 副标题 | 项目 | 印 | 印 |
| 标语 | 全局配置 | 印 | 印 |
| 版权行 | 全局配置 | 印 | 印 |
| 期标题 | 期 | — | 印 |
| 期数 | 期 | — | 印 |

两张画面由同一个构造函数按这张表取件。**封面跟项目、不跟期**，所以期标题与期数不
进封面，各期封面是同一张；**背景跟期**，两样都印。要加一行、要换归属，改这一处
就够——从前封面与背景各写一份元素序列，同一个元素在两处各描述一遍，改一处忘一处，
两张画面就各印各的了。

**动画默认静止。** 播客重点不在画面上，背景元素还会被缩放带偏。四档动画可选：

| 档位 | 说明 |
|------|------|
| 静止（默认） | 纯静态背景 |
| 缓推 | 全程匀速缩放，幅度可配（默认 1.04）。会带动背景元素位移 |
| 波形 | 半透明声波随音频律动 |
| 频谱 | 频谱条铺底，技术向 |

缓推档的缩放按整段帧数归一。写成「每帧加固定增量并设上限」会让运动全堆在开头：
六十二秒的片子在第 9.5 秒就撞顶，之后 52 秒完全静止。

**字体是可选项，两款各管一处。** 配置页「字体」卡片里两个下拉——**画面字体**（背景
与封面）与**字幕字体**，都在同一张卡上并排摆着。两个下拉都是自绘的：原生 `<select>`
的选项不接受自定义字体，浏览器直接忽略选项上的 `font-family`，整列字长得一模一样，
选字体就成了盲选。现在每个候选项按自己的字形渲染同一句样本，一眼可见长什么样；
本机没装的照样列出来、只是暗显不可选。留空即自动挑一款可用的。

**缺字形即报错。** 字体缺字形时渲染库不报错，直接画豆腐块。绘制前逐个字符检查覆盖，
缺失就报错并指出是哪个字——换符号、换字体，都比出一版带方块字的封面强。这一条在
自选字体之后更要紧：能挑的字体多了，挑到一款没有中文字形的（Inter、JetBrains Mono
这类）是迟早的事，报错会点名是哪个字。

**背景波纹按留白排布，不按像素。** 三条正弦波原本各自写死间距与振幅，而间距小于
相邻振幅之和——包络从一开始就是重叠的，再加上千分之三的频率差产生拍频，
相位周期性重合，三条线就绞成一团。现在先算单位振幅，按「相邻波带必须留出正留白」
反解出振幅，周期数按可见宽度归一（换画幅疏密不变），三个周期权重取不成整数比，
避免长期同相。

留白是结构条件而不是手感数值：相邻两条的中线间距取「振幅之和 + 留白」，
于是包络净空恒为 `留白 × 单位振幅`，与相位、频率、画幅都无关。
几何由 `wave_layout()` 单独算出，绘制与断言走同一份数据，测试量的是真正落笔的那条折线——
把留白改小、振幅调大、周期权重取成整数比，三条反证都会立刻报警。

**字幕两档，只差一个轴。** 两档共用同一个框：左右取 `margin_lr`、底边取画面底减 `margin_v`、
高度是「行数 × 行距」、填充与描边同一套写法——差别只在文字往哪个方向走。

| 档位 | 轴 | 一句话 |
|---|---|---|
| 歌词 | 纵 | 框内自动折行，换句时整块上滚（可见句数、框行数、上滚时长可配） |
| 单行滚动 | 横 | 一句一行、不折字；装不下就在框里滚过框口 |

**横滚滚的是溢出的那一截，不是整句。** 跑的是一段三段式：①句首静止（文字左缘贴框左，先让人读到
开头）②匀速左移（位移只等于「文字宽 − 框宽」）③句尾静止（文字右缘贴框右，结尾留在框里）。
速度取自语速（`文字宽 ÷ 句时长`），于是**滚动时长 = 句时长 × 位移 ÷ 文字宽，恒小于句时长**——
任何长度的句子都滚得完，不需要容差也不需要兜底分支。句首静止时长等于「框宽 ÷ 速度」，横屏默认
值下约 9.8 秒，与句子长短无关，正好是读完框内那一眼的时间。

跑马灯式「整句从框外滚到框外」要拿「整句过框」的时间，而观众只需要看没进过框的那部分——
多滚的那一截是白滚的：按句时长硬塞会快过朗读三成，按朗读同步则滚不完。**从前的单行档更直接：
它不滚，超出的部分被裁掉**（104 字那句实渲只剩中间约 33 字）。

字宽不用字号顶替，是实渲标定出来的：汉字 `0.751`、全角标点 `0.820`、半角 `0.438`（× 字号），
前后端读同一组常量。按「一个汉字一个字号」估会宽出三成，横滚终点会跑过框。这组数由测试钉死，
换字体就得重标。

---

## 九、目录结构

```
podcast-maker/
├── main.py                 入口（Web / 自检 / 续跑）
├── setup.bat               一键启动
├── requirements.txt
├── podcast_maker/
│   ├── config_manager.py   配置总表、枚举点位、预设、门禁定义
│   ├── layout.py           项目目录布局的唯一定义处：子目录名（含「音色」）、各期产物路径
│   ├── llm_client.py       LLM 客户端（约束解码、后端兼容、思考段剥离与 JSON 提取）
│   ├── ingest.py           素材导入与锚点切章
│   ├── script_engine.py    脚本生成、格式收束、片头尾粘合、门禁、锁文件
│   ├── duration_model.py   字数 ↔ 时长换算、标准语速与音色校准
│   ├── tts_engine.py       语音合成
│   ├── audio_engine.py     拼接、混音、编码
│   ├── subtitle_engine.py  断行、SRT/ASS 生成、时间轴
│   ├── assets_factory.py   背景、封面、BGM（含声波几何 wave_layout）
│   ├── video_engine.py     动画、说话人指示、合成
│   ├── project_store.py    项目登记表：规划方式、期号记忆、期数地图与分支期号、进度
│   ├── source_store.py     素材库：成稿入库、章节清单、按落点取原文（含行号定位）
│   ├── probe.py            结构探查：层级与原文位置、按文体定凝缩单元、准入、逐单元凝缩
│   ├── paradigms.py        素材范式：切分/整合/重点/推进四段（排图）+ condense（凝缩专用）
│   ├── planner.py          期数地图：合并与切分（期数由此定）、压比体检与整体重排、分组归一、落点、插入（与排图同管线）
│   ├── pipeline.py         九步编排与产物校验、后台任务（起任务/进度日志/同项目互斥）
│   └── web_ui.py           界面与 HTTP 接口（含选期、批任务与中止）
├── tools/
│   ├── ui_smoke.py         真实浏览器界面冒烟
│   └── e2e_http.py         走 HTTP 的端到端验收
└── tests/                  单元测试
```

---

## 十、测试

```bash
python -m unittest discover -s tests -v    # 单元测试
python main.py --port 8812                 # 另开一个窗口起服务
python tools\ui_smoke.py --url http://127.0.0.1:8812 --shot-dir _smoke
python tools\e2e_http.py  --url http://127.0.0.1:8812
python tools\e2e_http.py  --url http://127.0.0.1:8812 --scratch "$TEMP/pm_e2e/episodes"
```

`--scratch` 是验收产物的落点，默认在仓库内的 `_smoke/_scratch`。若宿主环境对
「非临时路径的删除」设了拦截，脚本开头清理上次残留那一步会被拦下——
此时把落点指到系统临时区即可，产物内容与判据完全不变。

1283 项单元测试，覆盖最容易出错的地方：字数换算、时长模型、字幕断行与均衡折行、
框几何与字宽标定（系数与实渲量出的宽对账）、两档共用一个框、单行横滚的三段式与「恒滚得完」、
回顾三条模板与只剃尾、配置迁移（旧档位与改名的点位）、
编码参数与 BGM 来源解析、
门禁规则、滤镜转义、排版包围盒与动画缩放曲线、配置点位对账、脚本契约、项目期号推进、
规划方式定死（三种）与单集出完即完结、素材类型落库、期数地图与分支期号、
素材索引完整性与按落点取原文、
料源按范式分流（成稿规划忽略手填、逐期即兴与单集用递来的）、
画面字体换一款真的换出另一张图、
项目目录布局与各期产物归位、批量的串行与失败跳过与熔断与软中止、
批量合成读盘上定稿那一份脚本、删除把登记表条目与项目目录一并抹掉、
单元位置与取料口径一致、按文体定凝缩单元层级、
md 锚点存活、结构探查与编号形态、声波包络不相交。

排版测试不看渲染图，只断言实测包围盒：相邻元素不压叠、框线在主标题之下、
字号取六十到二百四十共四档都不越界。

声波测试同样不看渲染图：按实际绘制点逐像素比对相邻两条波的上下关系，
任意 x 上都不允许反转。这一条必须由测试守着——留白被改小、振幅被调大，
界面上只表现为「波浪线又缠上了」，没有任何一处会报错。

配置点位测试做静态对账：扫描管线源码里读过的每个点位，与配置总表逐条比对。
悄悄读一个没声明的点位，取值永远落回默认值、界面上也没有控件去驱动它，
症状只出现在产出的图上——这类缺陷靠人看不出，只能靠对账。同一份对账还管三件事：
每个点位都必须有界面入口、枚举档位的标签不得等于值本身、每个点位都必须有阶段归属。

### 界面冒烟为什么必须用真浏览器

单元测试跑在 Python 里，看不见界面。而最贵的两类界面缺陷只在浏览器里现形：
控件找不到自己（点号被当成类选择符，`getElementById` 全返回 `null`，事件一个都没绑上，
滑块滑了不生效）、下拉只剩英文裸值。所以这一层用 Playwright 跑真浏览器，判据包括：

- 配置页控件数量与后端下发的点位数一致
- 界面里每个控件都在登记表里（没有「有控件、没登记」的漏网）
- 拖动滑块后服务端配置真的变了（回读确认）
- 枚举下拉的标签不是英文裸值；音色有候选可挑；模型名是下拉且点开能列全部
  （判据不是"有个能打字的框"——`datalist` 那种"输入框 + 候选"是浏览器的补全，
  值非空时候选被筛成命中项，框里填着 `qwen/qwen3.5-35b-a3b` 就只剩它自己，
  点开列不出其余 14 个。列表外的名字走末项「手动填写…」）
- 立项可选三种规划方式且默认选中一项；选「单集」时计划期数与起始期号收起，切回
  成稿规划再展开；脚本页有归属项目下拉并给出说明
- 两款字体下拉都是自绘且**每个候选项真的带各自的 `font-family`**——只验「有选项」
  会漏掉换回原生 `select` 时的形状：标签都在、值也对，字却全长一个样
- 单集卡片不写「下一期」，也没有「改下一期号」按钮
- 合成页只列有脚本的期（没脚本的期不进可选项）
- 选期表：全选 / 清空 / 单勾与计数一致，未选项目时整个收起
- 勾了多期走批任务，不落到单期接口
- 门禁在接口层也拦得住（缺脚本的批合成、空脚本存盘、中止不存在的任务）
- 删除要点两下：第一下只点亮、**一个请求都不发**，第二下才发 `action=delete`
- 插入面板自带入库区；已排进地图的素材默认不勾并标出；一份素材都没有时面板照样打开
- 控件 id 不含点号；无 console error / pageerror

后两条是补齐的教训：脚本页曾经整页读一个不存在的下拉（`s-project`），
一按生成就抛异常，而配置页那八十几项全都正常。控件数量对得上、值读得出，
都不代表这份界面能被用起来。

选期与批量那几条走的是**浏览器内的桩**：项目表、期表、批任务接口在页面里就地换掉。
这样「多期是不是真的走了批任务」「合成页是不是真的只列有脚本的期」能在真浏览器里
答出来，而服务端一期活都不干、落盘一个字节都不动——冒烟不该有副作用。桩里另接了
单期接口：多期若从那儿漏过去，判据就记一笔；删除请求也只记不做。

还有一类缺陷上面这些全都照不到：**运行时拼出来的 HTML 属性**。项目卡片的按钮是
拼串生成的，拼错一个引号，拼串本身仍是合法字符串——语法检查照过，浏览器要到点下去
才在控制台报错，界面上只表现为「点了没反应」。所以另有一道浏览器外的渲染体检
（`tools/probes/check_buttons.js`）：把卡片渲染函数真跑一遍，把拼出来的每个事件属性拿回来
逐个校验语法（修前 24 个里有 2 个不可解析），一并验下拉的过滤（归档项目、没排图的
成稿项目都不该进选项）。

### 端到端为什么要走 HTTP

单元测试各测一层，唯独串联处没人测：标题从脚本传到合成、期号从项目记忆里接着往下排。
`tools/e2e_http.py` 走界面用的那条 HTTP 路径，把两条路都跑通，产物落在临时目录，
跑完还原配置，不动真实产物：

1. **逐期即兴**：立项 → 脚本带标题 → 出片 → 期号前进 → 续接。
2. **单集**：立项（单集）→ 计划期数恒为一期、出完不再排下一期；画面照样印得出节目名。
3. **成稿规划**：立项（成稿规划）→ 上传 md 书稿（走 base64，验锚点还在）→ 排地图
   （逐节凝缩后分组，就地核对每期落点都切得出料）→ 生成脚本（素材区已收起，验日志写明
   按落点取用、标题取自地图）→ 在插入面板里就地传第二份素材（验库里份数增加、面板自行
   重画并勾上它）→ 让模型定插入点 → 落库（故意递一个错的期号，验后端没有采信）→ 核地图
   顺序与分支期号。

第二条是补齐的教训：只有真跑一遍才发现，排过地图的项目在生成脚本时仍被"素材为空"拦下。

---

## 十一、许可

Apache License 2.0，见 `LICENSE`。第三方组件声明见 `NOTICE`。

主程序与默认语音引擎这条链路上的许可都是宽松型——主程序 Apache-2.0、Qwen3-TTS
权重 Apache-2.0、推理加速层 faster-qwen3-tts MIT——**商用不需要额外授权或付费**，
义务只有保留版权与许可声明、修改处标注、附免责声明。唯一的例外是备选的 `edge-tts`：
库本身 LGPL-3.0，但产出的音频来自微软浏览器自用通道，受微软服务条款约束、权属不清。

要商用就选本地那条，并注意两件许可之外的事：**参考音频别用真人录音**（那份音频
由程序用模型自带的合成音色生成，走这条链路出来的声音不涉及他人声音权；换成真人
录音去克隆就落进《民法典》第 1023 条声音权保护的范围），发布时**按《人工智能生成
合成内容标识办法》加 AI 生成标识**。

作者：wUwproject


---

## 更新说明

## v1.0.0

**本次改动：README 补齐功能与用法、更新日志体例收紧（改写日志正文 60 处、README 新增 2 节、新增测试 6 项）**

**问题**

- **更新日志写成现场对白**：段里出现人称叙事（写明是谁提的）、产品定位论证
  （替产品该不该分发辩护）与口语措辞（只在口头成立的词）。读到它的人
  是后来查这件事的人，这三类内容都不承载「改了什么」，只把技术结论埋在话里。
- **README 只有设计论证，没有功能与用法**：通篇逐条讲的是「为什么这样做」，
  使用者读完仍不知道有哪些功能、按什么顺序点、产物落在哪；配置页十六张卡片
  只列了名字，没说各管什么。

**修法**

- 更新日志正文全量改写：人称叙事、产品定位论证、口语措辞三类一律去掉，只留
  **现象、根因、修法、验证**。体例区补一条文风条款。
- README 新增两节：**「二、功能」**按工序列能力与产物落点；**「五、怎么用」**给
  三种规划方式各自的操作路径（写明界面按钮名）、常见操作、配置页十六张卡片速查、
  出问题先看哪里。原「四、产物」及其后各节序号顺次后移，交叉引用同步。

**测试（1277 → 1283 项）**

- 新增 `tests/test_changelog_tone.py` **5 项**：三类词表（人称叙事 / 产品定位论证 /
  口语措辞）逐行扫描、命中即报行号；体例区那条款必须在场；每个版本段必须有加粗
  小标题。词表扫的是**正文**，跳过头部体例区——那里写着词表本身，扫进去是自证。
- 另在 `tests/test_config_keys.py` 的版本齐步组加 **1 项**：`ARCHITECTURE.md`
  抬头那个版本号也要与 `VERSION` 一致 —— 本次三端都齐了，唯独漏在架构册。
- README 的测试计数同步为 1283。

**版本：1.0.0**

功能面到此冻结。此后按语义版本管理：补丁修缺陷，次版本加功能，主版本只在
不兼容变更时动。

---
