Metadata-Version: 2.4
Name: podcast-maker-ldxs
Version: 0.34.4
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 :: 4 - Beta
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 矢量绘图层，当前说话方高亮 |

---

## 二、安装

需要 **Python 3.11 或更新**（`py -3.11` 或 PATH 上那个 `python` 都认），以及
[ffmpeg](https://ffmpeg.org/) 放进 PATH。然后：

```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 称呼与语速、生成与门禁结果、把改过的脚本存回本期 |
| 合成 | 归属项目、要合成哪几期（只列有脚本的期）、本期标题（改，不是填）、出片开关、实时日志、产物校验报告 |
| 配置 | 全部可调项，按命名空间自动分成十五张卡片：写作目标、门禁阈值、片头片尾、项目、语言模型、角色与音色、音频输出、背景音乐、画面输出、背景、动画、说话人指示、字幕、封面、字体 |

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

所有控件的取值范围都由后端下发，前端不硬编码任何选项。地址栏 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 无法解析」那一步，起因被埋在两个阶段之外。

---

## 四、产物

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

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

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

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

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

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

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

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

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

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

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

---

## 五、门禁

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

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

严格模式默认开启：宁可不出片，不出错片。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

位置比判法更要紧：它接在脚本生成的重试回路里，**不通过就带着检出的落点去定点修补**
（轮数取 `script.max_llm_rounds`）。等音频视频都渲染完再检，片子已经定死，检出来也改不动。

**内容检的输出必须带落点。** 每一项问题要给出 `{line, quote, problem}`：哪一句、那一句
里的原话、毛病是什么。这不是顺手加个字段，是定点修补的前置条件——检查只说「整篇偏题」
而不说哪一句，补丁就无处可下，只能退回整篇重出，而这正是要被根除的那件事。**指不出
是哪一句时要求模型老实填 `line: 0`**，代码据此把它归到「交人工」；句号越界也一律归 0，
不许拿第一句或最接近的一句顶替——填了假句号，改稿的人会去改一句没毛病的台词。

**模型没给结论，不等于检查通过。** 模型漏返回某一项时，那一项记为「未判定」落进待复核，
不默认放行——把「没检查」记成「检查通过」，报告上的绿灯比不检更坏。
判定为不通过的才参与放行（语义检 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. **规划轮写段主旨**——给每段写一句**段主旨**（与期主旨同层级：这一段讲的是什么事），
   并给全篇起标题。段数钉死，不能增删段、也不许写"从哪里起到哪里止"。**这一轮同样
   不看字数**——段边界是程序定的、配额是程序摊的，字数摆进来只会把"这一段讲什么"
   带成"这一段该多长"。

然后逐段生成、逐段按字数核账（容差与总时长门禁同一把尺），差额滚入下段配额——误差逐段
吸收，总账咬住目标。每段只喂**本段那几节的原文**，不再把整期素材过一遍闸门。

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

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

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

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

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

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

无论走哪条路，成稿都要先过一遍**程序硬去重**：同一句长句在整篇里出现多次时只留第一次。
模型凑数时会把写过的整段再背一遍（实测一期 302 句里 129 句是重复），那不是改写、是复播，
删掉没有任何信息损失。删了会记一笔「重复凑数」：说明这一期的素材其实撑不满目标时长。

其余全部 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()` 单独算出，绘制与断言走同一份数据，测试量的是真正落笔的那条折线——
把留白改小、振幅调大、周期权重取成整数比，三条反证都会立刻报警。

---

## 七、目录结构

```
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`。若宿主环境对
「非临时路径的删除」设了拦截，脚本开头清理上次残留那一步会被拦下——
此时把落点指到系统临时区即可，产物内容与判据完全不变。

572 项单元测试，覆盖最容易出错的地方：字数换算、时长模型、字幕断行、编码参数与 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 属性**。项目卡片的按钮是
拼串生成的，拼错一个引号，拼串本身仍是合法字符串——语法检查照过，浏览器要到点下去
才在控制台报错，界面上只表现为「点了没反应」。所以另有一道浏览器外的渲染体检
（`_smoke/_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


---

## 更新说明

## v0.34.4

**每种对话形式带一段「形状示范」（示例进提示词）**

把节奏写准、把上限写全，仍然不够用：模型照样一句一换。描述回答「应当怎样」，
示范回答「长什么样」——它得先看见一段。六种形式各带一条 `example`（三到七句
实例，文字与本期素材无关），由 `paradigms.example_block()` 渲染进**三处**：
整篇（`run_block`）、分段（生成要求第 2 条）、插入（铁律里的 speaker 条）。

- **只给形状，不给内容**：示范标题里明写「文字内容与本期素材无关——不许照抄
  这里的字」。不写这一句，模型会把示范当成「可以这么说」的候选句。
- **示范不许自己超限**：每条示例与它那种形式的上限一致（anchor 的示范是
  `B B B → A → B B B`），否则等于当着模型的面破规矩。
- **它不是固定序列**：只有 `qa` 锁死 `A B A B` 这条规矩不变；示范是一个可能的
  形状，不是必须照排的顺序。
- 同步：`tests/test_paradigms.py` 新增 `TestFormExample` 六条（字段完整、示例
  不超本形式上上限、示例每句本身过句长/标点/词表、形状可辨认、三处都到、
  未知形式给空串）；`test_script_stage_carries_only_the_run_caps` 的钉子从
  「`emotion` 这个键不许出现」改为「情绪基调不许回来 + 释义表不许贴第二份」
  （`emotion` 现在是形状示范里的输出字段之一，不再是第二份标签表）；
  `ARCHITECTURE.md` 1.3 节；本档。
