# 直播工程总控文件（LIVE ROOMS README）

> 本文件是直播工程的**核心定义**：产品是什么、领域模型、统一业务流程、渲染提供商适配契约、管理后台、服务拓扑。
> 其他文档都是它的附录。改业务定义先改这里。
> 业务原始需求：钉钉文档《直播间业务定义》（2026-10-06）。

## 1. 产品定义：三套方案，一个门户

| 方案 | 产品形态 | 大脑 | 嘴 / 身体 | 分发 | 状态 |
|---|---|---|---|---|---|
| **A 明星电话** | 30 位短剧演员 1:1 视频通话（陪伴） | Vidu 实时版自带 LLM（persona ≤5 万字） | Vidu S2 实时版 | Vidu AliRTC，按观众计费 ¥2.8/min | ✅ 完成，`vidu-call`，不动 |
| **B 数字人直播 · Vidu** | 1–2 位主播直播间，观众围观互动 | 我们：Letta agent ×N + character_room | Vidu S2 **组件版**（我们喂 PCM + 文本 + 动作） | 阿里 ARTC 一路推流 | 组件版跑通；跨境 WS 每 1–2 min 卡一次 |
| **C 数字人直播 · Vivix** | 同 B | 同 B | Vivix `vivix-a1-stream`（脚本模式 / 克隆音色） | Vivix 签发 Agora | 跑通；2 路并发、30 min 续期 |

**B 和 C 业务逻辑完全相同，只是渲染提供商不同。** 目标：一套业务代码，渲染器按直播间配置选择，而不是按部署环境选择。

终局：TikTok / 自有美国站点直播（见 `live-component-plan-2026-09-24.md`）。

**台词语言（2026-10-14）**：所有主播共用 `letta_bridge.protocol`，台词默认英文；观众用别的语言留言或点名要某种语言时用那种语言回答，之后回到英文。`format.language` 每轮重申；光靠提示不够（2026-10-14 两场开场仍是中文、被要求韩语后又回中文），所以 `character_agent.english_expected` 在每句出声前硬校验：非拉丁文字占多数的句子，只有待回应留言里有人用那种文字或点名要某种语言时才放行，否则打回重写；行长预算中文按 `max_chars`、英文按 `max_words`（22 / 70）；句子切分（流式、TTS 合并、claim guard）同时认中英文句号。人设文本仍是中文。

## 2. 领域模型

```
Character（角色）                      Room（直播间）                    LiveSession（一场直播）
├ profile   人设 30 字段（已有）        ├ name / slug                    ├ room_id, renderer
├ memory    Letta agent（已有）         ├ hosts[]  1–2 个 character_id   ├ provider sessions（每主播一路）
├ looks[]   服装/场景资产图（新：结构化） ├ background  起始背景图/合成方式  ├ 事件流（进场/评论/点菜/播出）
├ voice     TTS 配置 + 克隆 voice_id    ├ renderer vidu | vivix | heygem ├ 费用计数（会话分钟）
├ menu[]    粉丝菜单动作（新：可配置）    ├ menu 覆盖（可选）               └ 状态 waiting/live/closing
└ skills    主播功夫（已有 live_skills） └ status  off / live
```

### 2.1 Character

| 块 | 字段 | 现状 | 缺口 |
|---|---|---|---|
| 人设 | `name age background knowledge relationship personality speaking_style goal boundaries values interests inner_life source_material …`（`app/agent-config/*.json`，schema v9） | ✅ `/characters/config/{cid}` 可编辑、版本化 | — |
| 长期记忆 | Letta blocks / archival passages / messages | ✅ 查看、同步、清缓冲、删 passage | 「清空全部」一键（新） |
| 资产图 | `looks[]`：`{look_id, label, kind: portrait/full_body, url, description}`（config v10，M2）；`avatar`, `images[]` 仅展示/参考；2026-10-13 起每套衣服 4 个机位：`<look>` 全身（走秀/转圈/换装展示）、`<look>_knees` 膝上（kind `three_quarter`，原图 0.74 高度裁切）、`<look>_half` 半身到手（kind `half_body`，Flatkey `gpt-image-2.5-sunburst` 改画成手在腰前交叠，**默认机位**：开播起始、换装后停留）、`<look>_upper` 大头；唱歌每首随机取一个机位（`SHOTS` = full/knees/half/upper，中途换图会取消歌）；`resolve_look` 按 kind 切图时优先同套衣服；`stay_look` 换装后落在 `_half`；上限 32 张 | ✅ 后台「形象」Tab 增删/上传；Vivix 开播时把全部 looks 注册为 source_images，房间 `look` 决定起始图；动作 `needs_look` 时 `session.update(source_image_id)` 切过去、做完切回（`vivix_stage.perform`） | Vidu 不支持切图；缺同场景全身图时动作会跳场景 |
| 声音 | `voice{provider, model, voice_id, speed, pitch, emotion, vivix_voice_id}` | ✅ MiniMax 克隆 `*_live_20260914`，试听；✅ 「声音」Tab 上传 WAV → `POST …/voice/clone`（Vivix `/v1/files/audios` → `/v1/voices`）回填 `vivix_voice_id` | 克隆接口**未实测**（样本在 `docs/sources/voices/`，尚未克隆）；为空时 Vivix 播 MiniMax 音频 |
| 唱歌 | `songs[]`：`{song_id, title, url(https mp3/wav), mime_type, seconds, shots{full,half,upper}}`（config v11）+ 菜单 key `sing`；`song.menu_key` 可绑到菜单里任意 key（2026-10-10 起，原只认 `strum`）：绑定歌只在点那一项时唱、用那一项的形象与动作，不切镜头；形象里 `<look>_half` / `<look>_upper`（kind `half_body` / `upper_body`）是同一套衣服的半身 / 上身图 | ✅ 2026-10-09：江疏影 6 首（3 首 30 s 翻唱 + 3 首 fal MiniMax Music 2.0 原创曲，公版陈歌辛歌词，前奏裁到 3 s）；酒红 / 黑裙 / 奶白各三种景别（Flatkey gemini-3-pro-image 改画，图在 Vivix 图床）；大脑回复 `song` + `shot`，没填则 `pick_song` 随机不重复；`VivixStage.sing` 唱前切景别、唱完切回、会话剩余不够先续期（唱到一半换图会取消歌，已实测） | 后台角色页「歌单」Tab（`POST …/songs` 上传 → Vivix 托管 + ffprobe 时长 + 按默认全身形象生成三镜头动作描述，`app/song_assets.py`）；「形象」全身行「生成半身/上身」（`POST …/looks/framings`，Flatkey `IMAGE_MODEL` 默认 gemini-3-pro-image 改画 → Vivix 图床）；保存时有歌无「唱歌」项自动加 `SING_ITEM`。Vidu/HeyGem 渲染器仍是假唱 |
| 特别视频动作 | `clips[]`：`{clip_id, title, url, mime_type(video/mp4|webm), seconds, menu_key}`（config v13，2026-10-14）：提前录制好的表演视频，每段挂在一个菜单 key 上；观众点了那一项（礼物 / 网页点菜 / 主播自选）→ 主播先回应一句 → 观众页把这段视频**全屏叠在直播画面上**播放（带视频自带声音；`clip_prepare` 事件在主播念回应时就预载，`action_play` 带 `clip` 开播，播完或超时自动移除，底下的 Vivix 画面照常运行）→ 回到直播画面。同一动作多段视频随机、近期不重复（`CharacterRoom.pick_clip`）；有视频的动作不再走 Vivix 动作 / 歌（`own_menu` 给大脑 `video_clips` 而不是 `songs`）。渲染器无关（Vivix / Vidu 都是页面叠播），没有供应商回执，按时长 + 1 s 计完成 | ✅ 后台角色页「视频动作」Tab：选所属动作（或「新建动作」一键加菜单项）→ 上传 mp4 / mov / webm（`POST …/clips`：ffprobe 校验，非 H.264/AAC mp4 自动转码，存 `/data/character-room/materials/*.mp4`，`/api/characters/materials/` 支持 Range）→ 保存配置。姜伊夏「跳初雪」（`strum`）挂了 4 段海滩唱跳（720×1280）| TikTok 捕获页 / 无头录屏页能否带声自动播取决于该浏览器的自动播放策略（被拒时静音播并记日志）；1080 母版待升 |
| 背景音乐 | `bgm[]`：`{bgm_id, title, url, mime_type(audio/mpeg|wav), seconds, mood}`（config v14，2026-10-14）：主播说话时垫在声音下的音乐，**由主播大脑控制、不走 Vivix**。`tools.bgm` 每轮给大脑 `playing`（现在放的）、`tracks`（曲库：id / 歌名 / 运营写的氛围词）和 `reply`（怎么写字段，没音乐时提醒选一首）；回复字段 `bgm`（写在 draft 前）：某个 bgm_id = 从这句台词起换成那首，`"off"` = 关，null = 不动（未知 id 当 null，不重试）。`character_live.set_bgm` 在台词流出时发 `bgm{play,track}` / `bgm{stop}` 事件，观众页 `<audio loop>` 以 0.22 音量循环（`live-stage.js`，加入时从 snapshot.bgm 接上，自动播放被拒走「Tap for sound」）；唱歌和视频动作开始前引擎发 `bgm{pause}`、表演结束 `bgm{play}` 恢复（表演中大脑新选的曲子等表演完再起），普通动作不停。大脑写 null 而房间没音乐时引擎自己随机放一首（`chosen:false`；2026-10-14 首场 gpt-5.4 一直沿用旧回复格式不写字段，整场无声），`off` 后保持静音直到大脑再点一首。协议第 5 节告诉大脑按情景换、一首放几轮、不在台词里报曲名 | ✅ 后台角色页「背景音乐」Tab：上传 mp3 / wav（m4a 等转 128k mp3，40 MB 内，存 materials）→ 填「氛围」→ 保存配置 | 音量固定在页面常量；每首各自的音量与淡入淡出时长暂不可配 |
| 粉丝菜单 | `menu[]`：`{key, label, triggers[], needs_look, vivix_vmp, vidu_prompt, duration_ms, cooldown_s}`（config v10，M2） | ✅ 后台「粉丝菜单」Tab 增删、一键试做；姜伊夏预置 开合跳/深蹲/跳舞/转一圈/唱歌/比心 | 唱歌 = 念词 + 摇摆动作（Vivix 不会唱） |
| 主播 skill | `live_skills`（897 字）+ `initiative_policy` | ✅ 在人设里 | 把《主播功夫》12 条做成可勾选规则块，按角色口吻过滤 |

**素材统一 1080×1920（2026-10-13）**：Vivix `output.resolution` 官方只收 `480p` / `720p`（docs.vivix.ai sessions/create，当日核对），直播画面本身上限 720p，`VIVIX_RESOLUTION=1080p` 不存在；OBS 画布 1080×1920 会把直播画面放大 1.5×。能做到 1080 的是静态与预制素材：江疏影 27 套形象图全部重做成 1080×1920 母版（Real-ESRGAN x4plus 4 倍后 Lanczos 缩回；`_knees` 与全身同一张图），重新托管到 Vivix 并经 `PUT /api/characters/config/jiang_shuying` 换 URL（旧 URL 留在 `character_profile_versions`）；本地母版在 `app/assets/looks/jiang_shuying/`。`song_assets.OUT` 改为 `(1080,1920)`，以后生成的膝上/半身/上身机位直接是 1080（图像模型出图仍是 1024×1536，最后一步是 1.25× 缩放）。预制片段（菜单叠播视频）以后直接做 1080×1920；已有的 720×1280 海滩唱跳片用 fal `seedvr/upscale/video` 升到 1080p。注意：预制片 1080 清晰、直播画面 720 放大，两者切换时锐度有落差，属预期。

### 2.2 Room（新）

| 字段 | 说明 |
|---|---|
| `room_id, name` | 例：`duo_sofa` 双人沙发夜景；`liu_kitchen` 姜伊夏厨房；`shen_study` 申智安书房 |
| `hosts[]` | 1–2 个 character_id；2 个时启用 character_room 的竞争发言 |
| `background` | 起始背景图（上传）；双人时记录左右裁切与合成方式 |
| `renderer` | `vidu` / `vivix` / `heygem`；每间可不同 → B/C 同服务并存 |
| `layout` | `solo` / `duo_split`（两格） / `duo_composite`（同背景拼成一幅） |
| `look` | 起始形象 look_id（每位主播都要有；`''` = 各自第一套）；M2 |
| `menu_override` | （未做）可覆盖角色默认菜单；现在房间菜单 = 各主播 `menu[]` 合并，同 key 先到先得（`rooms.menu`） |
| `opening` | 开播固定开场白 + 动作（映射 Vivix `opening`，Vidu 用首句脚本）——**尚未送到 Vivix** |
| `status` | off / live；开播、下播按钮 |

### 2.2.1 观众点菜（M2）

`POST /api/rooms/{id}/menu {user, key}` → 记一条 `fan` 事件「点了「跳舞」」+ 进 `action_queue`（同 key 排队中 / 冷却中则返回 `queued`/`cooldown`）。评论里出现触发词也同样入队。直播中 `CharacterRoom.live_hosts` = 该房间主播：大脑只轮询他们、菜单只取他们的 `menu[]`（单人间不会再让申智安抢话）。

**特别视频动作（2026-10-14）**：菜单项挂了 `clips[]`（见 §2.1）时，`execute_action` 不调渲染器：选片 → `action_play{clip}` → 立即记 completed（证据注明是计时，不是供应商回执）并把 `live.action.hold` 置上 → 主循环马上生成下一句（大脑按回执说「跳完了」），Vivix 播放器在视频时长 + 1 s 结束前不送这句（`release_clip`）。2026-10-14 之前是播完再生成，姜伊夏跳完初雪后空了 18 s，观众问「怎么不说话了」。选片提前到 `take_action` 时（`action['clip']`，`clip_prepare` 事件），观众页在主播念回应那几秒把文件拉下来。
**主播自选动作（2026-10-07 晚）**：observation 带 `menu`（自己的菜单 + 冷却秒数），回复 JSON 可带 `"perform":"<key>"` → 校验（自己的、未冷却、本句没有 action_request）→ `CharacterRoom.self_action` → 这句播完立即 `play_action`。协议明说：没填 perform 不许说自己在做/做完了。
**自由动作（2026-10-07 晚）**：粉丝要的动作菜单里没有（开合跳、江南 style、走几步）→ 大脑回复 `"perform":{label,prompt,seconds,look}`，prompt 是它理解后写的英文 Visual Motion Prompt，runtime 只查形状（seconds 夹 4–12）就当菜单动作一样 `self_action` → `play_action` → Vivix `visual.prompt`；没有禁用词表、没有自由冷却、没有额外调用。菜单动作仍优先（礼物引导）。设计：`free-actions-design-2026-10-07.md`。Vivix `perform` 现在把最终 prompt 写进日志。
**延迟整治（2026-10-08）**：实测一条点菜 47–61 s = 大脑 9–14 s + 排队等前句 17–20 s + 自己这句 8–20 s + 动作 8–12 s（`free-actions` 同期时间线页）。改：① solo 房 `LOOKAHEAD` 2→1；② 直播中台词 ≤35 字、一事一句（只靠协议，不做运行时截断——用户定）；③ Letta 压缩改 `LETTA_COMPACTION_MODEL`（默认 haiku）+ 窗口 20k→40k；④ 网关首字节 >`LETTA_GATEWAY_TTFB_CAP`=10 s 自动换 fallback 模型重试一次；⑤ LLM 上游改 OpenRouter（`LETTA_GATEWAY_ANTHROPIC_BASE/UPSTREAM_KEY/MODEL_PREFIX`，网关把 `claude-sonnet-5-5` 改名 `anthropic/claude-sonnet-5.5`）。A/B：Flatkey 与 OpenRouter 同模型中位一样（3.3–3.4 s），OpenRouter 赢在多供应商换线。未做：sleep-time 让路（SDK 无频率参数）。
**直播间隔离（2026-10-07 晚）**：`events.room` 列；评论/点菜/主播台词/系统事件都盖房间号；观众页 `state?room=` 只看本房间；在播时 `ROOM.live_room` 让大脑只读本房间评论、`requeue_orders` 只补本房间。旧事件 room `''`，不在任何 `/r/` 页出现。
**重启补队列**：开播时 `requeue_orders()` 把 15 分钟内未出现「…表演了X」的「点了「X」」事件重新入队（队列在内存里，部署即丢）。**部署前先确认没有房间在播。**
**上下文隔离**：`participants` 和 recent 事件只取在播主播；粉丝评论只看 30 分钟内的。单人间 LOOKAHEAD 上限 2。
**Vivix TTS（2026-10-07 晚）**：角色 `voice.vivix_voice_id` 非空（内置如 `longanhuan_v3.6`/`longanlingxi`/`longanxiaoxin`，或克隆 `voice_…`）→ `LiveConversation.vivix_voice()` 为真 → 不再调 MiniMax，chunk 直接 ready（空 PCM），`say()` 发 `vocal.type=speech`。实测首帧 1.9–2.3 s（原 2.5–3.5 s + TTS）。瓶颈转为 Letta 决策+LLM 4.6–7.3 s/句。Juice 已切 `longanhuan_v3.6`。
**传输层 TRTC（2026-10-07 晚）**：`VIVIX_TRANSPORT=trtc`（默认；Vivix 官方 playground `realtime/my-avatar` 也用它，腾讯国内边缘节点）；观众页自带 `/static/vendor/trtc.js`（5.19.2），`provider` 为 `trtc` 时走 `joinTrtc`。Agora 保留（`VIVIX_TRANSPORT=agora`）。观众页每 5 s 把 fps/kbps/loss/rtt/quality/clients/audio 上报到 `live/playback {event:'stats'}` → Railway 日志 `[viewer <room>]`。无头 Chrome 实测（本机→TRTC）：672×1280 24 fps ≈1.45 Mbps rtt 10–19 ms。

**观众画面卡死（2026-10-09）**：4 人同看 `jiang_lounge`，1 人画面不动，服务端无 renew/错误（renewals=0）；日志里该观众每 5 s 上报的 stats 一模一样（`fps=24 kbps=1425 rtt=52`）= TRTC `STATISTICS` 已停、页面在发缓存值 → 该观众链路掉了，SDK（`Kw()=Infinity`）无限退避重连、不抛 `error`，页面只听 `KICKED_OUT` 且不重进，最后一帧挂屏。改 `live-stage.js`：`lost(why)` 统一重进；听 `CONNECTION_STATE_CHANGED`（DISCONNECTED）/`KICKED_OUT`/`ERROR`；`REMOTE_VIDEO_UNAVAILABLE` 清 `shown` 让重发布能再挂上；看门狗：15 s 无 stats 或连续 3 次 0 fps → 重进；`leave()` 先置 `joined=false` 免得自己 exitRoom 触发重进。

**2026-10-11 复盘（jiang_lounge 77 s 场）**：gpt-5.4 首字 1.6–2.4 s / 整句 3.0–4.0 s；Vivix 收台词后 1.2–2.0 s 出声；礼物→回应 15 s、→动作 19 s（10-08 为 47–61 s）；观众端 23–28 fps、freeze 0。修了两处：① `streaming_grounding.partial_draft` 把流里半截数字 `"urgency":0.` 当成完整值而抛 `流式字段缺少分隔符`，每句都退化为等整句（多 ~1.5 s），现等到分隔符再判；② 下播是 cancel 循环，记录一律写 `aborted`（后台显「中断」），现 `stop_requested` 区分，`aborted` 只留给崩溃/进程丢失。开播最慢环节是 Vivix 建会话（6 张形象图注册，本场 22 s）。
**大脑路由（2026-10-10 定稿）**：所有主播共用一套大脑，模型不在角色配置里（v12 起 `agent_model` 等字段已移除）。**主力** `anthropic/gpt-5.4`（Letta → `/letta-gateway/anthropic` → Flatkey，首字约 2 s）；**备选** `openai-proxy/openai/gpt-5.4-mini`（OpenRouter）。后台记忆/压缩 OpenRouter gpt-5.4-mini。链路里没有 Claude。改模型只改 livestream-stage 环境变量 `LETTA_DEFAULT_MODEL` / `LETTA_FALLBACK_MODELS` 后重启，每位主播的 Letta agent 在下次开播由 `ensure_agent` 同步。
**大脑模型/网关基准（2026-10-09）**：`llm-brain-benchmark-2026-10-09.md`（数据 `llm-bench-2026-10-09/`）。姜伊夏慢 = 人设钉死 `claude-sonnet-4-6` 走 Flatkey（端到端 7.5 s，最差 13.7）；江疏影写了不存在的 handle 反而回退到 `sonnet-5-5`（2.8 s，强制思考抖动）。**10-08 记的「网关已切 OpenRouter」从未生效**（三个 env 线上不存在，网关一直是 Flatkey）。Letta `openai-proxy/*` 全部 401（`model_endpoint` 留在 api.openai.com）。推荐：`openai-proxy/anthropic/claude-sonnet-4.6` 经 OpenRouter（端到端 2.2 s 稳）；最快 `gpt-5.4-mini` 1.5 s 需人设 A/B；Qwen 裸模型 1–2 s 但经 Letta 失控思考 13–35 s，暂不可用。待办见报告 §8。
**部署包瘦身（2026-10-09）**：Railway 收代码快照只给 ~90 s，本机到它只有 130–360 KB/s，38 MB 包经常 `Failed to create code snapshot`。`app/assets/seasons`（30 MB 海报）改放 `livestream-stage` 的 `/data/seasons` 卷（`railway ssh` 复制过去，持久），`seasons.anchor()` 仓库没有就回退 `DATA_DIR/seasons`；`.railwayignore` 排除后包 12 MB。只砍不回退会启动崩（`BROADCAST.load → still_segment` 读 PNG），踩过一次。代价：`/studio` 缩略图 404。回滚：GraphQL `deploymentRedeploy(id)`。
**菜单浮在画面上（2026-10-09）**：`#menu-panel` 从右侧栏移进 `.stage`，抖音礼物栏样式——右下角锚定（`right:4px; bottom:84px`，手机 70px）、每行右对齐、无底色，icon 19px + 文字 11.5px（手机 17/11）。icon 由 `live-stage.js` 的 `ICONS` 按菜单 `key` 映射（未知 key：`outfit*`→👗、含 `dance`→💃、其余 ✨），所以新增动作只要 key 合规就自带图标。`z-index:4` 盖过「等待开播」层，开播前也能点。菜单内容仍按房间取（`rooms.menu` = 该房间主播的 `menu[]`），每间只显示自己的。

### 2.3 观众事件（Provider 无关）

`join(name)` · `comment(name, text)` · `menu(name, key)` · `leave(name)` · `like(name, n)`。来源：自有页面 WS；之后 TikTok `tiktok-live-connector`、抖音抓取（已有 `tools/douyin-capture`）。

### 2.4 TikTok 推流画面（`/r/{room_id}?tiktok=1`，2026-10-11）

同一个观众页加 `?tiktok=1` 就是 TikTok 的输出画面：只剩画面 + 字幕 + 动作 toast，去掉右栏、评论框、点菜栏、昵称弹窗、主播角标；单人房画面铺满整个视口，双人房按 18:16 居中留黑边。开播前显示「即将开播」（底图是起始形象）。不登记观众身份、不发评论，评论/礼物由 `app/tiktok_bridge.py` 从 TikTok 拉进 `room_events`。**2026-10-12 起通用**：任何直播间（web / tiktok 都行）的 `/r/{room_id}?tiktok=1` 都可以直接作为 LIVE Studio 的 Link 源——开播时适配器读的账号 = 房间自己的 `platform=tiktok`+`platform_handle`，否则（web 房间）用本服务的 `TIKTOK_HANDLE`（Railway 变量，当前 `@tphjnqubkjiqn4282`，`rooms.tiktok_handle`）；账号没在播时适配器只是每 30 s 轮询等待。`/api/rooms` 每个房间带 `tiktok_handle` 字段可核对。抖音房间不读 TikTok。

本机 TikTok LIVE Studio 接法：Scene Editor → `+Add Source` → **Link**，URL 填 `https://livestream-stage-production.up.railway.app/r/jiang_lounge?tiktok=1`，分辨率选竖屏 1080×1920（或 720×1280）并拉满场景。开播顺序：先在 `/admin/rooms` 对该房间点「开播」（或 `?tiktok=1&admin=1` 同页操作），画面自动出现后再在 LIVE Studio Go LIVE；下播反过来。页面向服务端上报的播放统计 `ua` 以 `tiktok-stage` 开头，`/admin/rooms` 控制台里能分辨出这是采集端。

**2026-10-11 四场 TikTok 复盘（jiang_lounge，465/255/88/141 s）**：进场、评论、礼物、在线人数、下播事件都进了 `room_events`（Link 源页 `audio=1`、672×1280、23–28 fps）。修了五处：① TikTok 礼物到大脑只有「送出「Rose」」，没有网页点菜带的「点了比心」（`payload.for` 只有 `menu_pick` 设），`ingest` 现在先解析礼物对应的才艺再写粉丝行，并按英文礼物名别名（Rose→玫瑰、Corgi→柯基…）找菜单；② 同一位进场观众被问候四次（两句播出、两句被查重拒绝），期间 Umi 的问题等了 65 s——进场/关注/分享在主播点过名字后或 90 s 后退出 `pending_fan_comments`（`GREET_WINDOW`）；③ 站姿主播反复下蹲：每句说话提示词都带 `do not sit, kneel or crouch`，视频模型把否定后的姿势词当提示，`embodied_scene.constraint` 改为纯正向描述（未做 A/B，下场对照）；④ 适配器的 connected/not live/stats 只发给当时在看的 SSE，现同时打到 stdout（`[tiktok <room>]`，Railway 日志可查）；⑤ 网关回退 gpt-5.4-mini 返回 400 但只记状态码（单独用同样 key 调 Flatkey 的 gpt-5.4-mini 是 200，问题在 Letta 完整请求体），现打印错误体前 600 字。**录像**：这四场采集库只有控制日志（提示词、台词、时序），没有画面/声音——当时录像只在 `?rec=<token>` 页面开启（Vivix 的 RTC 凭证只允许观众页这一路订阅，服务端录不了）。**2026-10-12 起默认录（2026-10-13 改为 `?rec=auto` 才录，见 02:20 场）**：观众页（含 `?tiktok=1`）开播后自动把 MediaRecorder 的 5 s 片段 POST 到本服务 `/api/rooms/{room_id}/record`，服务用自己的 `VIVIX_COLLECTOR_TOKEN` 转给 vivix-collector，页面里没有 token；同一场只允许一个录制端（先到者持有租约，20 s 没片段才换人，其他页面收到 409 即停），多开观众页不会互相覆盖；`?rec=<token>` 仍按原样直传。收集器 `COLLECTOR_KEEP_FULL=1`，整场 `video.mp4` 与每句 clip 都保留。声音对比：用这份录像（LIVE Studio 听到的原始音频）对照 TikTok 回放，才能定位声音差是 TTS（`longanfengyue`）、TRTC、Link 源采集还是 TikTok 转码。

**2026-10-12 02:19 场（222 s）复盘**：礼物链路首次跑通——Umi 的 Rose → 粉丝行「送出「Rose」（1钻），点了比心」→ 23 s 后点名感谢 → 36 s 时比心执行完成；「唱歌」评论被正确引导到摩托车；进场没有重复问候；新正向姿势提示词已在用。两个新问题已修：① ericstory 的 Rose 排进的比心被下一条评论打断（「表演前台词播放中断」）直接作废——付费表演现在重新排到队首、下一句台词后执行（只重排一次）；② **这场仍然没录到**：日志里零次 `/record` 上传、零次新版脚本请求，两个观众页（Link 源 + Chrome）都是部署前打开的旧页面。观众页现在带 `SCRIPT_VERSION`，每 15 s 对比 `/api/rooms/{room}.asset_version`，不在播时自动 reload（LIVE Studio 的 Link 源没有刷新按钮）；播放统计行多了 `rec=`（idle/proxy/direct/off/err:…）和 `v=`，Railway 日志能直接看到录制是否在跑。

**TikTok 礼物对齐（2026-10-12，jiang_shuying 菜单 revision 12）**：用 TikTokLive `fetch_gift_list` 拉了 @tphjnqubkjiqn4282 的真实礼物表（710 个；原来假定的「礼帽 Top Hat」在该账号目录里不存在）。每个菜单项写入 `gift_name` / `gift_diamonds` / `gifts=[id,name]`，按 id 或英文名精确匹配：

| 菜单 | TikTok 礼物（测试价，revision 13，全部 ≤30 钻） | id | 钻 | 正式价建议（revision 12 用过） |
|---|---|---|---|---|
| 比心 heart | Rose | 5655 | 1 | Rose 1 |
| 飞吻 kiss | Finger Heart | 5487 | 5 | Finger Heart 5 |
| 撩头发 hair | Rosa | 8913 | 10 | Doughnut 30 |
| 转一圈 spin | Perfume | 5658 | 20 | Paper Crane 99 |
| 走两步 catwalk | Doughnut | 5879 | 30 | Corgi 299 |
| 跳一段 slow_dance | Bouquet Flower | 5780 | 30 | Money Gun 500 |
| 唱歌 sing | Bravo! | 19443 | 15 | Motorcycle 2988 |
| 弹唱 strum | Hi Bear | 8692 | 10 | Lion 29999 |
| 变装·黑裙 | TikTok | 5269 | 1 | Galaxy 1000 |
| 变装·奶白 | GG | 6064 | 1 | Fairy Wings 1000 |
| 变装·酒红 | Ice Cream Cone | 5827 | 1 | Fireworks 1088 |
| 变装·宝蓝 | Heart | 6247 | 1 | Diamond 1099 |
| 变装·西装 | Thumbs Up | 6246 | 1 | Gold Mine 1000 |
| 变装·粉裙 | Cake Slice | 6784 | 1 | Blooming Ribbons 1000 |

用户 2026-10-12 要求测试期所有礼物 ≤30 钻（菜单不变），故当前线上是左列；正式上线时把右列写回去即可。TikTok 自己的「观众心愿」面板（LIVE Studio 工具）是 TikTok 侧的展示功能，我们没有任何 API 能读写它——本系统与 TikTok 的全部连接只有一条：`tiktok_bridge.py` 用 TikTokLive 以观众身份读直播间事件；LIVE Studio 只是把观众页当 Link 源加载，不受我们控制。观众心愿列表须在 LIVE Studio 里手动改成与上表一致（礼物 + 文案），否则观众照心愿送的礼物会按我们的表（含兜底）执行而不是心愿文案。

**礼物板烧进画面（2026-10-12，commit 938a687/8463f27）**：用户不想手动维护观众心愿，改为由我们统一展示——`?tiktok=1` 页面右侧常驻一块只读礼物板（图标 + 动作 + 礼物名 + 钻），随画面一起推到 TikTok，观众在 TikTok 礼物托盘里照着送即可。图标就是 TikTok 的礼物图标本身：`app/static/tiktok-gifts/<gift id>.webp`，用 TikTokLive `fetch_gift_list` 从本账号目录拉的（已存测试价 14 个 + 正式价 11 个；换礼物时用同样方法补图，脚本片段见 git log 938a687），`rooms.gift_icon` 按菜单项 `gifts[0]` 的 id 找图，`/static/tiktok-gifts/{id}.webp` 由 portal 路由提供（static 是逐个路由，不是 mount）。网页菜单也用同一套图标。板子 `pointer-events:none`，不登记、不点菜。

不在表里的礼物（Rosa 10、GG 1、Heart Me 1、TikTok 1……）按钻石数兜底：买「它够得着的最贵一项表演」（Rosa→飞吻、GG→比心、Fireworks 若未映射→走两步），变装只认自己的礼物、不做兜底；0 钻不触发。改礼物：/admin/characters 里改该项的 `gift_name/gift_diamonds/gifts`，大脑引导话术（`tools.menu[].gift`）和观众页按钮同步变化。

**礼物只认账号真实礼物表（2026-10-12）**：`tools/tiktok_gift_catalog.py` 用 TikTokLive `fetch_gift_list` 把本账号 705 个礼物（id / 英文名 / 钻 / 图标 URL）存到 `app/static/tiktok-gifts/catalog.json`，`--icons` 把菜单绑定的礼物图标下载成 `<id>.webp`，`--cheap 30` 列出 ≤30 钻的礼物供选。`room_events.gift_for(item)` 是唯一定价入口：菜单项 `gifts=[id,name]` → 目录项；没绑定的按 key 默认（heart→Rose、spin→Paper Crane、dance→Money Gun、sing→Motorcycle、变装→Galaxy、其他→Paper Crane），全部仍是目录里的真实礼物；原来的中文假礼物表（礼帽 99 等）已删除。保存人设时 `validate_menu` 拒绝不在目录里的礼物 id/名、与目录不一致的钻石数。网页点菜发出的 gift 事件带真实 `gift_id/name/diamonds`（外加 `key` 指明点的哪项），与 TikTok 真实礼物事件同形，大脑看到的是同一句「送出「Rose」（1钻），点了比心」并排队表演；礼物板图标 = 本地 `<id>.webp`，没下载时用目录里 TikTok 自己的图标 URL。姜伊夏（liu_lian，revision 61）14 项全部绑定 ≤30 钻真实礼物：GG 1 / Bravo! 15 / Bouquet Flower 30 / Finger Heart 5 / Blow a kiss 1 / Name shoutout 5 / Let 'Em Cook 20 / Pumpkin Pie 5 / Cheer You Up 9 / Dolphin 10 / Gold Boxing Gloves 10 / Beach Radio 10 / Energy Capsule 30 / Spinning Soccer 5。

**主播完全不说话（2026-10-13 姜伊夏 liu_terrace 两场，0 句）**：不是技能问题（`app/skills/host_playbook.json` 对所有角色相同，含 silence 10 s 的独白条目），是大脑每句都 400：`context_length_exceeded`（922k 上限，请求 925k）。查 Letta：liu_lian 代理 in-context 496 条消息 / 1.40M tokens，jiang_shuying 443 条 / 1.15M（快到墙）。根因：压缩摘要模型 `LETTA_COMPACTION_MODEL=openai-proxy/openai/gpt-5.4-mini` 在 Letta 侧 401（Incorrect API key，model_endpoint 仍是 api.openai.com——与 10-09 基准里 openai-proxy/* 全 401 同一问题），每次 Summarizing 都失败，缓冲区只增不减；另外 liu_lian 核心记忆块 33k tokens + 系统 6.4k + 工具 1.5k = 41k 已超过 40k 窗口，压缩永远达不到目标。处理：① 两位代理空闲时 `reset_messages`（记忆块、归档不动）：liu_lian 1.40M→41k，jiang 1.15M→16k；② `LETTA_COMPACTION_MODEL=anthropic/gpt-5.4`（主模型同一条通路，确定能用）、`LETTA_CONTEXT_WINDOW=80000`；③ `letta_bridge.speak` 遇 `context_length_exceeded` 自动重置缓冲并重试一次（不再静默）。副作用：代理丢失本轮之前的对话上下文（人设/粉丝/经历块保留）。sleeptime 记忆代理仍在 `openai-proxy/openai/gpt-5.4-mini`，同样 401，场间记忆整理很可能一直没跑——待确认后换模型。

**礼物板精简（2026-10-13）**：`?tiktok=1` 画面只显示 动作名 + 礼物图标（去掉礼物名和钻石数，板子变窄）；「Switch host」换人项不是礼物、TikTok 观众点不了，采集页不显示。网页观众页不变。

**2026-10-13 00:18 场复盘：声音失真 + 开 LIVE Studio 就卡**：本机录像（`~/Movies/Record_2026-10-13-00-19-24`，720×1280 HEVC 1.16 Mbps）音频：0–2 s 歌声峰值 0 dBFS（astats 峰值 +3.9 dB，削波），2–7 s 与 16–23 s 全数字静音（-90 dB），8–13 s 台词也顶到 0 dB。歌曲源文件 `IF YOU` 本身 -10.3 LUFS / 真峰值 +0.4 dBTP（太热），台词 TTS 明显更轻，两者混在同一路 Link 源里只能二选一地削波。网络：公司网走 Tailscale（utun13，MTU 1280）→ 198.18.x 假 IP → HZ 办公网代理机出海，`networkQuality` 空闲延迟 330–340 ms、负载下响应 2.3 s（严重 bufferbloat），上行 18–22 Mbps；TRTC（Vivix 画面）走 en0 直连国内 10–40 ms。服务端播放统计：Chrome 观众页 131 样本 avg 24.7 fps / 0.9% 丢包；Studio 内嵌页 14 样本 avg 18.4 fps / 13.7% 丢包，基线 rtt 197 ms，推流时 rtt 263→398→728 ms、丢包 41–63%、fps 0–3——Studio 的 TTNet（`enableProxyNativeNetwork`）把内嵌页的 TRTC 流量也带进了隧道，推流一开始上行被占满，画面源本身就卡，再被编码推走。本机内存 16 GB 用了 15 GB、swap 5.3 GB、mediaanalysisd 60% CPU。Railway 不在视频路径上（只有页面/API/SSE）。结论：卡顿根因是出海链路（隧道延迟 + bufferbloat）而非 Railway；失真根因是歌曲音量过热 + 隧道丢包导致的静音/补偿伪影。待办：歌曲 loudnorm 到 -16 LUFS / -1.5 dBTP 再上传；Studio 降到 720p30 ≤1.5 Mbps、关掉 Link 源的音效滤镜、源音量 -6 dB；长期把采集+推流放到海外机器（OBS 浏览器源 → RTMP）。另：该场无礼物但大脑自己唱了《IF YOU》——自发演唱目前允许（只拦截评论点单），是否要禁待定。

**歌曲音质差的定位（2026-10-13 01:28 jiang_lounge《遇见》，OBS 路线）**：三处取样同一场——① 原始 mp3（Vivix 上存的和本地 md5 一致，未动）：44.1k 立体声 192 kbps，-12.2 LUFS，>12 kHz 能量 -52 dB、>16 kHz -67 dB；② 观众页录像（collector `ff7f4cdd`，OBS/Studio 之前，TRTC 直出）：**单声道**，-18 LUFS，真峰值 +1.1 dB，>12 kHz -76 dB、>16 kHz -91 dB——Vivix 发布的是 24 kHz 单声道语音级音频，12 kHz 以上全没了，立体声塌成单声道；③ Studio 本机录像：-4.4 LUFS、真峰值 +5.1 dB，每个 10 s 窗口都顶 0 dB——从 OBS 监听口（音量 1.0、无滤镜）到 Studio 录像之间多了 +8～10 dB，是 Studio 把 BlackHole 当麦克风做了增益/人声增强，削波失真在这一段。结论：音乐“闷/平”来自 Vivix 的 24k 单声道管线，“破/刺”来自 Studio 麦克风增益。处理方向：Studio 里关掉该麦克风的降噪/人声增强/自动增益并把增益拉低到录像峰值 ≤ -3 dB；歌曲要原音质只能绕开 Vivix 的音频——唱歌时观众页本地播放原始 mp3（OBS 浏览器源直接出声）、同时静音 TRTC 音轨，Vivix 只负责口型（起播时刻以 `response.render.started` 为准，预计 ±300 ms 偏差，需实测）。

**2026-10-13 02:39 场（51 s）与 OBS 路线**：用户报「直播无法启动」。服务端这场正常：开播 5 s 后 Vivix 会话建好、两个观众页（Link 源 `ua=tiktok-stage` + Chrome）均 25 fps、`audio=1`，大脑 4 句台词已出声；TikTok 侧在这 51 s 内一直 `not live`（用户未在 LIVE Studio 完成 Go LIVE），用户 02:40:12 手动下播。录制仍失败：两页都是开播后才加载的 v22，首个 5 s 片段 POST `/record` 时服务端抛 `starlette.requests.ClientDisconnect`（客户端中途断开请求体），零片段到达收集器——待查。随后按用户指示改走 **OBS 虚拟摄像头**：`tools/obs-tiktok-setup.sh [room]` 写好 OBS 配置（profile「TikTok Stage」1080×1920@30、scene「Stage」只有一个 Browser Source 指向 `/r/<room>?tiktok=1`、「Control audio via OBS」+ monitor-and-output、监听设备 BlackHole 2ch）并以 `--startvirtualcam` 启动 OBS。链路：观众页 → OBS Browser Source → OBS Virtual Camera（只有画面）+ BlackHole 2ch（声音）→ LIVE Studio「相机」源 + 麦克风选 BlackHole。OBS 虚拟摄像头不带音频，所以 BlackHole 必装（`brew install --cask blackhole-2ch`，需管理员密码）；macOS 26 首次启动虚拟摄像头要在「系统设置 → 通用 → 登录项与扩展 → 相机扩展」里手动允许 OBS。OBS 本体 `brew install --cask obs`（32.2.2）。

**2026-10-13 21:17 PDT 场（535 s，TikTok 9:15 / 86 钻，LIVE Studio 机器搬到美国）复盘**：链路合格——美国采集页、Mac mini 录制页、本地 Chrome 三端全程 24–27 fps、0 冻结、丢包 0–0.5%、TRTC rtt 20–50 ms（上一场 Studio 内嵌页 18 fps / 丢包 14–63%）。缺陷：① 460 s 起到下播主播哑了 75 s——455 s 的 Bravo!（唱歌）排进 `action_request`，但 `sing` 还在 120 s 冷却（398–428 s 刚唱《起风了》），`character_agent.validate` 把每句带 song 的台词都以「正在冷却」拒绝，7 轮 × 2 次重试全废，粉丝 492 s 问「怎么不唱了」无人应，这笔 15 钻没兑现；② 5 笔礼物里 2 笔（116 s、365 s 的 Bravo!）的引入台词被紧随的评论打断（`表演前台词播放中断`），表演重排、多绕一整圈，礼物→表演 43 s / 33 s；其余 21–52 s（排在前一个表演后面）。修法（同日）：付费点单不受冷却限制（`validate` 对 `action_request` 命中的菜单项跳过 cooling；提示词同步）；评论不再打断带 `action` 的引入台词（`comment_received` 跳过）；引入台词改为一句话 25 字内。主播自选表演仍遵守冷却（防她自己连唱同一首）。录像：collector 会话 `3133c337`（10795 帧、46 句、按句切 clips）；美国机器无本地录像，TikTok 回放在 livecenter 需手动下载。歌声在 TRTC 直出已是 -13.4 LUFS / 峰值 -0.5 dBFS（台词 -19.8 LUFS），Studio 侧再加增益就会削波。《后来》前 12 s 手一直捂在嘴边（拿话筒的姿势没有话筒）。

**同场 TikTok 回放核对（livecenter 下载，720×1280 30 fps，视频 768 kbps / AAC 128 kbps，550 s）**：画面比服务端时间轴晚约 20 s（Go LIVE 时刻），音画同步，无黑场，唯一冻结是 511 s 下播后的「Starting soon」静帧；画质较 TRTC 直出略软，无块状伪影。声音：整体 -18.1 LUFS、真峰值 -0.1 dBFS、零削波（上次 Studio 麦克风增益问题已消失）；但歌声 RMS -17 dB、台词 -24 dB，歌比台词响 6–7 dB，台词对 TikTok 偏轻——该把歌曲文件 loudnorm 到台词档（约 -18 LUFS），不能抬台词（歌会顶爆）。静音段：每次换装/走秀表演 19–21 s 全程无声（画面有动作、没有声音），265–286 s、295–314 s 各一段；439 s 起 111 s 静音 = 冷却 bug 的 75 s 沉默 + 下播后静帧。

**2026-10-13 23:52 PDT 场（360 s）复盘 + 提速改造（2026-10-14）**：用户反馈「说话太慢、评论一条一条回」。数据：说话占比 63%（静音 128 s：Vivix 每句起播 1.7 s × 34 句 ≈ 58 s，大脑等待 ≈ 70 s）；语速 5.57 字/s（speed 1.1）；每轮中位 26 字。根因一：Flatkey 当天把 `gpt-5.4` 下线（改名 `-fk` 但上游也 "does not exist"，`gpt-5.5` 路由坏），这场 176 次 503、0 次成功，每轮先失败再切 mini 再切回，中位多等 6.5 s，台词由 mini 在 106k 的超窗上下文里写（朱小北 9 句车轱辘）。根因二：每条评论立刻打断预生成台词并起新回合，回合里只接一条。改动：① 主模型 `openai-proxy/openai/gpt-5.4-mini`（OpenRouter；基准 2.8 s/轮、JSON 3/3、能一轮合并回 4 条；5.4 为 5.0 s、nano 会写舞台提示、5.6-luna 6 s），备用 `openai-proxy/openai/gpt-5.4`，压缩也在 mini；`LETTA_CONTEXT_WINDOW` 80k→36k（代码下限），每轮 prompt 从 ~70k 降到 ~25k。② 评论聚合：`comment_received` 新评论等 `LIVE_COMMENT_WINDOW`（3 s）再一次性打断，礼物立即；提示词要求一轮合并回最新 ≤5 条。③ `VIVIX_TTS_SPEED` 1.2（上限）、`LIVE_MERGE_CHARS` 120。成本（OpenRouter，USD/M tokens，in/out/cached）：gpt-5.4 2.5/15/0.25；5.4-mini 0.75/4.5/0.075；5.4-nano 0.2/1.25/0.02；5.5 5/30/0.5；5.6-luna 0.2/1.2/0.02。Letta 键 10 月累计 $37.5，当天 $0.96（基本是这场 mini + sleeptime）；按 25k prompt × 8 轮/分钟估：mini 约 $0.5–1.7/h（缓存命中决定），5.4 约 $1.5–6/h。

**2026-10-14 00:31 PDT 场（300 s，提速改造后第一场）复盘**：大脑已走 OpenRouter（网关 0 次调用），前 85 s 正常；之后 OpenRouter 持续 402 `in_flight_budget_exhausted`——账户总额 $40 已用 $39.17，余额 $0.83，不够给单次请求预留；两段 57 s / 58 s 沉默都是它。放大器：江疏影 agent 的 Letta 消息缓冲涨到 203k token / 167 条（`context_window` 36k 对 Letta 0.16 的压缩不起作用，只警告 "Context token estimate is not set"），每轮把 20 万 token 重发，既贵又触发在途预算；朱小北车轱辘话、「这个先不聊」也是在这堆垃圾上下文上生成的。处理：① 用 `POST /api/characters/config/jiang_shuying/memory/reset` 清空缓冲（203k→0）；② `LETTA_AUTOCLEAR=1`，每步后自动清空消息缓冲（记忆块、归档不受影响；观测本身带 conversation_timeline，不依赖 LLM 历史），每轮 prompt 固定约 9–15k；③ OpenRouter 需充值，这是下一场能不能开的前提。另：下播记录 `6cf467a72809` 停在 live（stop 按了 4 次，日志里 `session ended` 之后没有 `closed →`），下次开播 `recover_broadcasts` 会把它标成丢失；stop 卡在哪待查。录制 `ClientDisconnect` 仍在。

**2026-10-14 00:50 PDT 场（204 s）复盘：实测成本 + 「转不了一圈」循环**：OpenRouter 用量差 **$0.383 / 204 s ≈ $6.8/h**（mini，13 句 + 14 次查重重试 + sleeptime 24k 上下文；自动清空生效，主 agent 缓冲 2.6k）。大脑无 402/503；礼物 81 s → 开口 85 s（4 s）。问题：Perfume 的转一圈、随后的跳一段都在 Vivix `session.update` 切 `black_dress` 全身图时报 502001 "first-frame staging failed"（10 s 超时），两次表演失败且不重排；之后大脑在 receipts.failed + 粉丝追问下连说 7 句「没成/转不了一圈/只能站着」，查重拦下 14 个尾句，用户评价「很傻」。开场两句仍是朱小北（self_state 记忆块残留上几场话题）；「个人生活/喜欢什么男生」被「先记着」拖了三次。修（commit 见 git）：① `update_session` 失败后 1 s 重试一次；② 付费表演「未获得成功回执」也重排一次（原来只有「播放中断」）；③ 提示词：失败只解释一句、不承诺无执行字段的替代动作；同一问题被追问就正面答；④ 用 `PUT …/memory/blocks/self_state` 把 3.3k 字的旧场次叙事换成一段近况。

**2026-10-14 01:21 PDT liu_terrace 场（747 s，姜伊夏）复盘**：实测 **$0.843 / 747 s ≈ $4.1/h**（mini）。5 笔礼物全部执行，礼物→开口 3–6 s，Vivix 切图无失败，大脑无 402/503。问题：27 句台词对 **57 次「几乎一样」拒绝**——自动清空缓冲后每轮从同一份观测出发、写出同一句（「这块我真给老师留着了」连拒 10 次、「我不重复那句了」连拒 8 次），轮内重试只改两个字；表演后的 after_action 节拍总是复述引入句。cassie 无礼物点歌时她又想唱刚唱过的《IF YOU》（冷却拦下，处理正确）。修：被拒的句子（最近 5 条）随观测回传 `show.rejected_recently`，重试指令要求换开头换句式，after_action 不复述引入句；提示词同步。

**2026-10-14 01:41 PDT 场（127 s）：同一条评论答两遍 = 程序 bug**：Andrew 问「拍戏最吓人的事」，86 s 和 88 s 两句几乎同一个夜戏故事。原因：`context()` 的 answered 只统计 `status=spoken` 的台词，排队未播（prepared）台词的 `reply_ids` 不算，于是预生成的下一轮又答一遍同一条评论；自动清空缓冲后 LLM 也看不到自己上一轮写了什么，叠加放大。另：`FAN_WINDOW` 30 min 让上一场的「你不跳我要生气了」在这场开头仍是待回应（开场 3 s / 7 s 两次被拒的句子就是在回它）。修：answered 只排除 cancelled/interrupted；待回应留言的时间下限取 max(FAN_WINDOW, 本场 started_at)。成本 $0.134 / 127 s ≈ $3.8/h（mini）。模型对照：用户要求换模型实测智力差异——自 02:0x 起主模型切 `openai-proxy/openai/gpt-5.4`（备用 mini），对照 mini 的 01:21 liu_terrace（57 拒/27 句）与 01:41 jiang_lounge。

待验证：Link 源是否带页面声音（OBS 的 Browser Source 带；LIVE Studio 若不带，改用 Window 采集 Chrome 窗口 + 系统声音）；Link 源里无法点击，若出现 🔇 提示说明自动播放被拦，需换 Window 采集。

**2026-10-13 02:20–02:30 PDT 场复盘（jiang_lounge，collector `04f2716a`，589 s，LIVE Studio Link 源 720×1280@30 H265 1500 kbps ABR）：声音已基本解决，剩「太卡」**。三层各自量化，互不混淆：
- **出海推流链路（观众看到的卡的主因）**：RTMP 入口 `198.18.62.218`（假 IP）经 Tailscale `utun4`（MTU 1280，DNS 100.100.100.100）出海。LIVE Studio SDK 日志（`~/Library/Application Support/TikTok LIVE Studio/logs/*-server_sdkv2.txt`，`stats cache updated` 每 2 s 一条）289 个样本：编码 fps 恒 30，但**实际码率中位数 1032 kbps，49% 样本 <1000、10% <500（最低 255）**；rtt 中位 253 ms，36 个样本 >500 ms，26 个 >1 s，最高 **8.7 s**（02:20:55–02:21:23 一段 3.2→8.7 s，02:22:53、02:25:39、02:26:03、02:28:11 多次 1.3–3.1 s）。ABR 在每次拥塞时把画面压到 1/5 码率，rtt 数秒 = 发送缓冲堆积，观众端就是糊 + 停顿。下播后空闲 `networkQuality`：空闲延迟 339 ms、负载响应 **5.5 s**、上行 15.6 Mbps、下行 **3.5 Mbps**（出口在 HK 边缘）。Railway 不在视频路径上。
- **采集页（Link 源内嵌页）自身停顿**：同一台 Mac，Chrome 观众页 109 样本 24–27 fps 零次 <15 fps；`tiktok-stage` 页 109 样本中 **9 次 <15 fps（2 次 0 fps）、14 次 rtt>300、17 次 quality≥4**，与推流 rtt 尖峰同一时刻（02:21:01 fps=2 rtt=1550）。该页的 MediaRecorder 第 83 片跨了 15.8 s（主线程卡住），之后 02:25 起上传几乎全部 `ClientDisconnect`（120 次失败 / 19 次成功，2.5 MB 片段几分钟都传不完）。**默认录制从采集页以 4 Mbps 往外传，正好压在推流同一条上行上**——自伤；录像因此只收到 12 片 = 开头 35.7 s（`video.mp4` 672×1280 25 fps 本身流畅）。本场没有 LIVE Studio 本机录像（`~/Movies` 最新是 01:30 场）。
- **Vivix 句间空档（结构性，观众感知为「说一句顿一下」）**：78 句，说话 379 s（64%），**句间空档 60 次共 103 s（17%），每次 1.60–2.10 s，排队（`after_current_response`）与否一样**（本地 8 个会话 42 对相邻响应中位 1.66–1.75 s，无一低于 1.55 s）。空档期间人物**硬切回起始图站姿**（手从身侧跳到交握，录像 5.5 s / 14 s 处可见），80 句 = 80 次跳变。另 ≥2.5 s 静默 17 次共 96 s（16%）：三次 14–17 s 分别是 `guitar_sofa` 切图失败（Vivix `502001 no available dit instance for version a107-next-prod-1.0.7.3 in cluster wlcb`，10 s 超时后道歉）、唱完《永远的微笑》后的接话、再次尝试《遇见》；其余 3–5 s 是大脑整句时延（gpt-5.4 ttfb 1.0–1.7 s / 整句 2.4–3.8 s，两次 8.2 s、9.9 s）。
- 不是原因：Vivix→TRTC 画面本身（Chrome 页恒 24–27 fps、1.4 Mbps）、Railway、大脑排队。
- 用户决定（同日）并已做：① **录制不在本机，但默认都录**（与 collector 流程一致）：观众页照旧默认录（经本服务转发；`?rec=<token>` 直传），**唯一例外是 LIVE Studio 的 `?tiktok=1` 采集页不录**——除非它的 UA 带 `livestream-recorder` 标记（`live-stage.js` v25）。录制机 = Mac mini（tianpeng-macmini 100.64.0.180，macOS 26.3.1，Chrome 153）：`~/bin/macmini-recorder.sh` 已装成 LaunchAgent `com.livestream.recorder`（`--install jiang_lounge liu_terrace`），每个房间一个无头 Chrome（UA 带标记，DevTools 127.0.0.1:9229/9230，日志 `~/Library/Logs/livestream-recorder.log`），开播自动录、下播自动停、开机自启；一场同一时刻只有一个录制端持有租约，Mac mini 常驻先到先得，其他页 409 即停。Railway 上录作为备选未做。**事故**：v24 的 `live-stage.js` 漏掉 `recToken`/`recorderId` 声明，脚本一加载就 `Uncaught ReferenceError`，03:53 场所有观众页（网页版 + Link 源）全黑 95 s，用户下播；v25 修复并加 `tools/check_viewer_page.sh`（无头 Chrome 抓 console，部署前后各跑一次）。v24 的页面脚本已死、不会自愈：Chrome 刷新即可，LIVE Studio 的 Link 源要删掉重加或重启 Studio。另：页面自动换版前先 `fetch` 自身成功再 reload，避免刷新撞上断网把 Link 源留在错误页。
- **20:06–20:12 PDT 场（collector `16befb29`，339 s，58 句）验证结果**：① 语速 1.1 生效：实测 5.54 字/s（02:20 场 4.98），+11%。② 采集页接上了（`tiktok-stage` 65 个样本，中位 25 fps，4 次 <15 fps、11 次 rtt>300），另有一个普通页 32 个样本；TikTok 侧整场 `not live`。③ **合句没起作用**：大脑每轮固定两句（短句 8–16 字 + 长句 20–41 字），首句按设计单发，"后续"只剩一句，无可合并；且上一轮没播完播放器不开下一轮，于是每轮 = 2 条响应 = 2 个 1.7 s 空档，空档 57 次共 98 s = **29%**（说话 68%，静默 0）。改法（已改、待这次部署）：轮次开始时已经写好的句子直接合成一条（上一轮在播时大脑早已写完下一轮，所以通常整轮一条），只有还没写出来的句子才等，首句永不等；`LIVE_MERGE_CHARS` 70→80；视觉脚本 `duration_ms` 随台词时长 4–12 s（原固定 4000）。预期每轮 1 条响应，空档减半到 ~15%。④ **没录到**：Mac mini 07:54 起离线（Tailscale `offline, last seen 12h`，SSH 不通），LaunchAgent 在但机器不在线；本机只开了 Studio 采集页（按规则不录）。Mac mini 需保持开机不休眠（系统设置 → 节能 → 防止自动进入睡眠），录制才能自动发生。② 直播时本机只开一个观众页（用户执行）。③ **说话连贯**：Vivix TTS 语速 `pipeline_config.tts_config.payload.speed`（文档确认内置 Qwen 音色支持，0.7–1.2），环境变量 `VIVIX_TTS_SPEED` 默认 **1.1**；台词时长估算随之 ÷1.1。**合句**：一轮的首句单独发（保首字延迟），之后的句子在上一句还在渲染时攒成一条 `response.create`，上限 `LIVE_MERGE_LINES=3` 句 / `LIVE_MERGE_CHARS=70` 字，带手势标签的句子另起一条（动作先播）；上一句剩余渲染时间内等后续句子，最多等到结束前 0.8 s。预期：本场 60 次句间空档 → 约 25–30 次。字幕按合并后的整段推送；打断语义不变（`response.cancel` 取消当前+排队）。视觉提示 `duration_ms` 仍为 4000（6.4 s 的句子实测照常渲染到音频结束），未改。推流出口/海外采集机仍是长期项。

**原声唱歌（2026-10-14 晚，用户：「播放音乐时候音色太差，用我原生音乐替换 Vivix 出来的声音，保持声音完全一致」）**：上面 10-13 的定位成立——Vivix 发布的是 24 kHz 单声道语音级音频，歌曲 12 kHz 以上和立体声全没。现在（`SONG_LOCAL=1` 默认开）唱歌时观众页/采集页自己播放歌曲原文件（Vivix 上存的那份，`static.vivi-x.ai` 公网可取、支持 Range，和本地 md5 一致），同时静音主播的 TRTC 音轨，Vivix 只负责口型。引擎 `song` 事件：`prepare`（接到点单时把候选文件 URL 发给页面预加载，采集页离文件服务器远）、`start`（`VivixStage.sing` 收到 `response.render.started` 时经 `on_started` 发出，带 `sync_ms`/`volume`）、`stop`（演唱结束或失败，`finally`）。页面：`songStart` 延迟 `SONG_SYNC_MS`（默认 0）后播放、`hostAudio(true)` 静音 TRTC；自动播放被拒则保留 Vivix 的声音并亮「点击出声」，不会无声；`seconds+5` s 的兜底计时防止 `stop` 丢失后一直静音；特别视频播放中不动主播音轨。`SONG_VOLUME` 默认 0.7（10-13 回放里歌比台词响 6–7 dB）。**首场验收（10-14 晚 jiang_lounge《永远的微笑》）**：用户确认音质好；画面比原声慢约 1 s，原声放完 Vivix 还唱 1–2 s（页面一解除静音就听到 Vivix 自己那份的尾巴）——两者同一个偏移：Vivix 画面和它的音频到页面比 `render.started` 事件晚 1–2 s。用户：「要知道 Vivix 开始播放的事件，不要写死时间」。现在页面把主播的 TRTC 音轨接进 AnalyserNode（`hostMeter`，录制页同样的 `getAudioTrack`），`start` 后每 40 ms 读 RMS，连续两次 >0.02 即 Vivix 的歌声真的到了这页，此刻从文件首个有声处（服务端 ffmpeg silencedetect 测的 `lead_ms`，接到点单时预测、按 URL 缓存；《永远的微笑》177 ms）起播原声；静音改用 `setRemoteAudioVolume(0)` 而非 mute，订阅继续流、表还能听。`stop` 后等该音轨连续 4 次 <0.02 才恢复主播音轨。拿不到音轨或 `lag+4 s` 内没听到才退回 `SONG_SYNC_MS`（1200）；页面日志写 `Vivix heard after N ms` / `Vivix quiet after N ms`。**第二场（08:31 jiang_lounge《遇见》）**：两页都报 `Vivix heard after 1622/1587 ms`、内存文件、分析器 running，即原声与 Vivix 音轨对齐在 ±0.1 s；用户仍判「完全没对上」→ Vivix 的画面和它自己的音轨到页面的时间不一致，且首个有声点只对一次不够。现在（v38）起播后页面抓 6 s Vivix 音轨，与原声做 FFT 互相关（±2.5 s，峰值/次峰 ≥1.3 才采信），按测得偏移 seek 原声一次（`[song] phase=align lag_ms=… applied_ms=…` 进日志），再加 `SONG_AV_OFFSET_MS`（默认 0，正=原声更晚；Vivix 画面相对其音轨的固定提前量，靠耳朵定一次）。**下播后音乐不停**：下播取消演唱动作时其清理 `resume_bgm` 在 `stopped` 之后又发了 `bgm play`；引擎 `stop_requested` 后不再发 bgm，页面 `ended` 标志下忽略晚到的 bgm/song/action 事件，15 s 房间刷新发现未开播也会停。录制页（livestream-recorder UA）不走原声，保留 Vivix 音轨，录像才有声音。live-stage.js v35。校准：`?songmix=1` 让 TRTC 音轨以 15% 留在原声下面，录一场后对两路做互相关得到偏移，再填 `SONG_SYNC_MS`（预计 ±300 ms）。**第三场（10-15 02:09 UTC《夜上海》，collector `1b09d559`）定位到根因：Vivix 把给它的音频播得比实时快约 9%。** 证据：① Mac mini 录像里 Vivix 那份 17 s 走完了原曲 18.6 s 的内容（谱图块匹配，斜率 1.098），音高不变（时间压缩，不是采样率搞错）；② 直接探针：12.0 s wav 作 `vocal.type=audio`，`render.started→stopped` 11.14 s（tts speed 1.0）/ 10.98 s（1.2），与会话语速无关；③ 观众页 stats 一直是 26–27 fps（流是 25 fps）。所以 v38 的一次性对齐之后原声每 10 s 领先口型约 1 s，整首越来越离——与用户三次反馈一致（早先「动作快于画面 2 s」也是它）。这个速率是对方服务的、会变，用户要求不写死（v39 曾用常量 1.09，已撤）。现在（v40）全靠测量：页面起播后每 ~5.5 s 抓 3–4 s Vivix 音轨，算成 10 ms 一帧、32 个对数频带的谱图帧，在「Vivix 正在唱的那份文件」（`start.ref_url`：原版，或服务端放慢版）的帧上同时搜偏移 ±2.5 s 和速率先验 ±4%，得到 Vivix 此刻在该文件的位置和它的真实播放速率 `vivix_rate`（分数 ≥0.45 且比 ±150 ms 外最高分高 ≥0.06 才采信）；原声按 位置/stretch、`playbackRate = vivix_rate/stretch`（保音高）跟住，偏差 ≥350 ms seek 一次、60–350 ms 用 ±4% 推 lag/0.04 s。每次定点上报 `[song] phase=align n ok lag_ms score second vivix_rate rate stretch applied`，引擎把 `vivix_rate` 学成 EMA 存 `materials/vivix-rate.json`（初始 1.0、0 次）。有 ≥2 次定点且速率偏离 1 达 1% 后，下一首先用 ffmpeg `atempo=1/rate` 放慢（保音高）、ffprobe 量出 `stretch`、`POST /v1/files/audios` 上传一次（`song-r<rate×100>-<md5>.mp3` + `.json` 旁车），Vivix 对口型的是这份，播快之后刚好回到原曲速度；第一首（还没学到）Vivix 唱原版，页面跟它的速度走（同步但快几个百分点）。`stop` 时分析器已关闭（唱完切回机位换了音轨）直接按固定 lag 恢复；等待中途关闭也同样处理（v40 曾读 4 s 旧读数）。**v40 在线上从未对齐过一首歌**（10-15 《后来》日志：`phase=align how=failed songFetch(...).then is not a function`——文件入内存后 `songFetch` 返回的是字符串，`songRef` 对它 `.then` 抛错，于是只有首个有声点的一次起播、之后随 Vivix 的 ×1.10 漂走）；v41 让 `songFetch` 恒返回 promise，并加了 node 回归（scratchpad `songfetch_test.mjs`：缓存前后 `songRef` 都能解析）。注意：同样的速率也作用在台词上（Vivix TTS 1.2 再 ×~1.09，MiniMax 1.06 再 ×~1.09），是「说话太快」的一部分；`VIVIX_TTS_SPEED` 可考虑回 1.0–1.1。待验证：采集页 `<audio>` 的声音能否进 OBS/LIVE Studio（bgm 同一路径，10-14 凌晨用户反馈「好像没有播放音乐」尚未定论）——若进不去，原声和 bgm 一起没有，需在 OBS 浏览器源勾「通过 OBS 控制音频」。live-stage.js v34。

**节奏与情绪（2026-10-14，用户：「说话速度太快、基本一样，真人有快有慢」）**：大脑在句首写标签，引擎读完即删、不念出（`motions.PACES` / `EMOTIONS`，走动作标签同一条流式通道 `split_cues → cues → enqueue`）：`[slow]` = 重点句，**单独一个 response 出声**（Vivix 每个 response 前的 1.2–2 s 本身就是"留一拍"，不再额外 sleep），≤16 字 / 9 词、每轮至多一句，超长或第二句自动降为普通句（`pace_downgraded` 指标）；`[fast]` = 合句上限放宽到 4 句 / 160 字；`[happy] [surprised] [sad] [angry] [neutral]` 从这句起换声音情绪。**MiniMax 音色**（姜伊夏 / 申智安）每句按 `sentence_voice()` 带独立 `speed`（profile 语速 × 0.85 / 1.15，`LIVE_PACE_SLOW/FAST`）和 `emotion`，并按 `profile.language` 传 `language_boost`（英文台词此前一直写死 Chinese）；**同时自有 TTS 的句子也合句**了（`drain_group` 把几句 PCM 拼进一个 response，此前每句各付一次 response 空档）。Vivix 内置音色（江疏影）只有分组效果，没有变速/情绪——Vivix 探测结论：`vocal.speed` 静默忽略、会话 tts_config 不可改、任何层级都没有情绪字段，每句变速只能靠多 avatar 切换（每次 2.5–3.6 s + 可能回位），不采用。回放里每轮一行 `[节奏] 第 N 轮：k 段出声，重点句×a 快句×b，情绪 …`。规则在协议第 3/7 节和每轮 `format.pace`（bgm 的教训：只写系统提示会被忽略）。**延迟实测（10-14）**：MiniMax 流式首字节 0.14–0.32 s、整句 1.1–1.5 s；Railway↔AutoDL 盒子一次往返 0.8–1.9 s，盒上 Qwen3-TTS 出整句 4.9–10.5 s → 本地 TTS 现阶段反而更慢，先在 MiniMax 上做逐句控制；本地化调研见 `docs/local-tts-research-2026-10-14.md`。

### 2.5 观众页只显示英文（2026-10-11）

观众页 `/r/{room_id}`（含 `?tiktok=1`）面向欧美观众：所有界面文案硬编码英文；菜单按 key 映射短英文（`live-stage.js` 的 `LABELS`，未知 key 自动 Title Case，绝不显示中文 label）；礼物名按 `GIFTS` 映射成 TikTok 官方英文名（玫瑰→Rose、礼帽→Top Hat…，未知中文名显示 Gift）；单人房不显示主播名（角标、字幕署名都隐藏），评论回执只显示 Replied/Pending。后台与 brain 仍用中文 `label`，不受影响。房间名直接上屏，须在 /admin/rooms 用英文（jiang_lounge 已改为 Jiang Shuying · Night Lounge）。主播说话的字幕是什么语言由大脑/TTS 决定，此处未改。

## 3. 统一业务流程（B/C 共用）

```
开播(room)
 ├ 为每位 host 建渲染会话（provider.start）→ 等 ready → 播 opening
 ├ 主循环（character_room，已有）：
 │   ├ 有观众事件 → 导演层排序（评论 > 进场 > 点菜 > 续讲）→ 选发言人
 │   │     评论：打断当前未播句（provider.interrupt + 作废队列），回完接回话头
 │   │     进场：3 秒内带名字欢迎 + 记忆点（主播功夫 #1）
 │   │     点菜：匹配 menu → 需要全身则 provider.switch_look → provider.action(vmp) → 切回
 │   ├ 无事件 → 空闲计时（3 s）→ 续讲：agent 出下一句（章节大纲 + 悬念留人）
 │   └ 每句：agent 逐句流式 → TTS（或 provider 自带 TTS）→ provider.say → render_started 点亮字幕 → render_stopped 写记忆「已说出」
 ├ 双人：听者 provider.listen_cue（看向搭档/点头）；`[both_laugh]` 双路同时 action
 └ 下播 → provider.stop 全部 → 费用结算写 LiveSession
```

主播功夫 12 条（`docs/live-host-skill-manual-2026-09-24.md`）落在导演层规则 + `response.instructions`/导演提示，不塞人设背诵。

## 4. 渲染提供商适配契约（`app/renderers/`）

所有 provider 实现同一接口；业务层只调接口。

```python
class Renderer:
    async def start(host, look, voice, opening) -> ready_info      # 建会话，返回观众接入凭证
    async def say(host, text, audio: bytes|None, vmp: str|None)    # 一句话：文本+音频(可无)+随句动作
    async def action(host, vmp, duration_ms)                       # 纯动作（菜单）
    async def listen_cue(host, vmp)                                # 听者微动作（可选）
    async def switch_look(host, look_id)                           # 切图：换装/景别/场景
    async def interrupt(host)                                      # 打断当前
    async def stop(host)
    events: ready / render_started(host, line_id) / render_stopped(host, line_id, status) / closed(reason) / error
```

| 接口 | Vidu 组件版（`vidu_stage.py`） | Vivix（`vivix_stage.py`） |
|---|---|---|
| start | `POST /live/s_avatar/component` + WS conn_init；ARTC token 自签 | `POST /v1/realtime-avatar/sessions`（9:16/720p，agora，`opening`，`interaction_idle=0`，`recording off`）+ WS |
| say | type10 文本 + PCM 24k 匀速推（必须我们出音频） | `response.script.vocal`: `speech`（Vivix 克隆音色，2.2 s）或 `audio` base64（我们音色，2.8–4.5 s）+ `visual.prompt` |
| action | type9 动作词（句间发） | `response.script.visual.prompt` / `visual_prompt_asset_id` |
| listen_cue | 无原生；type9 小动作 | `pipeline_config.motion_planner.prompt`（建会话时写死风格） |
| switch_look | 换背景 type11（换不了人） | `session.update(source_image_id)` 等 `update.done` |
| interrupt | type7 | `response.cancel` |
| 观众端 | 阿里 ARTC Web SDK 订阅 `vidu-host` | Agora Web SDK，Vivix 签发 token |
| 时长/续期 | ≤2 h；跨境 WS 卡顿自动重建 | Starter 30 min；到期前先关后开（~5 s 黑场） |
| 成本 | ¥113/h/主播 | $0.32/min/主播（Starter）≈ ¥138/h |

## 5. 管理后台（`/admin`）

统一一个后台，三个菜单，复用现有页面与 API。

| 菜单 | 页面 | API（✅ 已有 / ➕ 新增） |
|---|---|---|
| **角色** 列表 / 新建 | `/admin/characters` | ✅ `GET/POST /api/characters/catalog` |
| 角色 · 人设与记忆 | 现有 `/characters/config/{cid}`（并入后台导航） | ✅ `GET/PUT /api/characters/config/{cid}`，✅ memory view/sync/reset，➕ `POST …/memory/clear-all` |
| 角色 · 形象资产 | 同页 Tab「形象」（M2 ✅） | `looks[]` 随 `PUT /api/characters/config/{cid}` 保存；上传复用 `POST …/images` |
| 角色 · 声音 | 同页「声音」（M2 ✅） | ✅ voice-preview；✅ `POST /api/characters/config/{cid}/voice/clone`（WAV → Vivix 克隆 → 回填 `voice.vivix_voice_id`；未实测） |
| 角色 · 粉丝菜单 | 同页「粉丝菜单」（M2 ✅） | `menu[]` 随 config 保存；✅ `POST …/menu/try {key}` 一键试做（要求主播在播） |
| 角色 · 主播 skill | 同页「主播功夫」 | ✅ `live_skills` 字段；➕ 12 条规则勾选 → 生成到 `live_skills` |
| **直播间** 列表 / 新建 | `/admin/rooms`（M2 ✅，`app/static/admin-rooms.*`） | ✅ `GET/POST /api/rooms`，`PUT/DELETE /api/rooms/{id}`（hosts/renderer/layout/look/background/opening/note） |
| 直播间 · 四个页签（2026-10-10） | `/admin/rooms/{id}`：**直播控制台**（开播/下播、SSE 事件日志、每句延迟、在播分钟 + 费用估算）；**直播记录**（这个直播间＝一个平台账号，每次开播一场：开始、状态、时长、句数、评论、礼物、钻石、点赞、关注、粉丝数、峰值在线；点一场看它的观众事件时间线，按类型筛选）；**粉丝**（账本：跨场次来访/评论/点赞/礼物/钻石/关注）；**基础设置**（平台 + 平台账号、主播、画面、开场） | ✅ `live/control` / `live/state` / `live/events`；`GET /api/rooms/{id}/broadcasts`（直播中的场次实时统计）、`…/broadcasts/{bid}/events?kinds=`、`…/fans`；列表项带 `platform / platform_handle / last_broadcast` |
| 直播间 · 观众页 | `/r/{slug}`：单人间 = 竖屏整屏画面 + 菜单区 + 评论区（白板布局，M2 ✅）；双人间沿用两格 | ✅ `live-stage.html`；`POST /api/rooms/{id}/menu`；评论仍走 `/api/characters/comments` |
| **总览** | `/admin` 现等于 `/admin/rooms`；provider 余额/限额总览未做 | ➕ `GET /api/admin/overview`（Vidu 积分、Vivix 会话、Railway 服务健康） |

鉴权（2026-10-08 起账号登录）：`/admin/login` 账号密码（`ADMIN_USER`/`ADMIN_PASSWORD`，默认 shulex/shulex123）→ `POST /api/admin/login` 发 HttpOnly cookie `admin_session`（HMAC，30 天）；`app/admin.py:require_admin` 认 cookie，也认脚本用的 `x-admin-token: ADMIN_TOKEN`；`LOCAL_STUDIO=1` 本机免登录。**同事 token（2026-10-08）**：`ADMIN_TOKENS="kexin:…,xiaoyu:…"`（Railway 变量，本机副本 `.env.admin-tokens`）每人一把，走同一个 `x-admin-token` 头，`require_admin` 返回人名并打日志 `[admin <name>] <动作>`；不是 cookie 密钥，可单独吊销（改变量后重新部署）。角色新建/保存、上传、记忆写入、试听现在都要登录。给同事 Codex 的说明：`docs/codex-operator-guide.md`。后台页面统一由 `/static/admin.js` 注入同一个顶栏（总控台 / 角色 `/admin/characters/{cid}` / 直播间 `/admin/rooms`，右侧用户名 + 退出），页面加载先 `GET /api/admin/me`，未登录跳登录页；403 同样跳登录。观众页与 `/menu`、评论公开。旧 HeyGem 页（`/characters/live`、`/characters/classic`）的开播按钮不带口令，已随 HeyGem 失效。

## 6. 服务拓扑

### 现状（2026-10-07，M1 完成后）

| Railway 服务 | 栈 | 作用 | 处置 |
|---|---|---|---|
| `vidu-call` | Node `tools/vidu-s-demo` | A 明星电话 | **保留不动** |
| `livestream-stage` | Python，B + C 合并 | 门户 `/`、直播间 `/r/{room_id}`、`/api/rooms`；渲染器按房间（`duo_sofa`=Vidu，`duo_sofa_vivix`=Vivix） | **正式服务**；M5 改域名 `livestream-portal` |
| `letta-stage` | Letta 0.16.8 | 记忆库 | 保留，唯一大脑 |
| `livestream` | Python，`RENDERER=heygem` | 旧生产双人间（HeyGem） | 依赖的 pod 与旧 Letta 已没了，实际已死 → 待下线（M5） |
| ~~`vidu-stage`~~ / ~~`livestream-vivix`~~ | — | Node POC / 独立 C 服务 | 已删除（2026-10-07） |

M1 验收记录：同一服务 `duo_sofa_vivix` 开播 6 s 就绪、5 句播出（开口中位 3.1 s）；期间开 `duo_sofa` 被拒「正在直播，请先下播」；换 `duo_sofa` 26 s 就绪、ARTC 频道两主播在线。171 个 unittest 通过。

### 目标

```
门户 https://livestream-portal…  (Python livestream 服务，1 个)
 ├ /                 首页：A 明星电话（代理 vidu-call 名单）+ 直播间列表
 ├ /r/{slug}         观众页（竖屏）
 ├ /admin/*          管理后台
 └ renderer 按 room.renderer 选 Vidu / Vivix；两家同时在线
vidu-call（A，不动）      letta-stage（记忆）      外部：Vidu API / 阿里 ARTC / Vivix / MiniMax / Flatkey
```

## 7. 数据（SQLite，`/data/character-room/room.sqlite3`）

已有：`character_profiles`, `character_profile_versions`, `letta_agents`, 评论/发言表。
M1/M2 实际落法（JSON 进现有表，不另开表）：
- `rooms(room_id, config JSON, at)`：config = `{name, hosts, renderer, layout, look, background, opening, note}`；读取时与 `rooms.SEED[DEFAULT_ROOM]` 合并补新字段。
- `looks[]`、`menu[]`、`voice.vivix_voice_id` 在 `character_profiles.config`（schema v10，`character_profiles.load` 自动升级并保留用户改动；种子在 `app/agent-config/{cid}.json`）。
- 场次表 `broadcasts(broadcast_id, room_id, platform, platform_room_id, started_at, ended_at, status, stats)`（`broadcast_id` = 直播 session），观众事件 `room_events`，粉丝账本 `fan_ledger`（`app/room_events.py`）。费用仍只在控制台估算，不落库。

## 8. 里程碑

| # | 内容 | 验收 |
|---|---|---|
| M1 ✅ 10-07 | `rooms` 表 + `/api/rooms` CRUD + `/r/{room_id}`；renderer/hosts 按房间（`LiveConversation.configure`）；B/C 合并到 `livestream-stage`；一次只允许一间直播 | 同一服务里 `duo_sofa(vidu)` 与 `duo_sofa_vivix(vivix)` 都能开播 ✅ |
| M1b ✅ 10-07 | 首页改为能力入口页：A 卡片（在线状态 + 演员头像）、B/C 直播间卡片（动态、标渲染方案与价格）、后台三卡（角色 / 直播间 M2 建设中 / 总控文档 `/docs/*.md`） | 用户验收首页 |
| M2 ✅ 10-07 | 角色 config v10：`looks[]` / `menu[]` / `voice.vivix_voice_id`；角色页 Tab（人设/形象/粉丝菜单/声音/记忆/模型）；`/admin/rooms` CRUD + 控制台；`POST /api/rooms/{id}/menu` 点菜；`CharacterRoom.live_hosts` 单人间只轮询在播主播；Vivix 多 look 注册 + `perform()` 切全身图做动作再切回；`require_admin` 统一口令；单人观众页竖屏布局（画面 + 菜单 + 评论）。姜伊夏预置 6 个动作，厨房半身 look `/assets/looks/liu_lian/kitchen.png` + 客厅全身 look。176 unittest 通过；本机 headless Chrome 走通：后台建 `liu_kitchen` 单人间 → 观众页点「跳舞」入队 | **待用户验收**：生产开播 `liu_kitchen`，点菜动作命中、切图回切；**未做**：Vivix 克隆实测（先用 MiniMax 音频）、`opening` 送 Vivix、观众数、provider 余额总览 |
| M3 | 单人间讲故事循环 + 进场欢迎 + 评论打断；双人观众页竖屏合成 | 15 min 不冷场；评论 3 s 内回应；菜单动作命中 |
| M4 | 双人合成同屏（同背景裁切 + 听者 cue + 看向搭档） | 观众看到两人对视、接话 |
| M5 | 清理：删 `vidu-stage`；处置 `livestream` 旧服务；门户域名 `livestream-portal` | 服务数 = 4（portal, vidu-call, letta-stage, +?) |
| M6 | 推流：自订阅 → ffmpeg → RTMP（自有站点 HLS / TikTok） | 外部播放器看到流 |

## 9. 附录索引

- 业务进度与素材：`live-rooms-progress-2026-10-05.md`
- 组件版/美国区/成本：`live-component-plan-2026-09-24.md`
- Vivix 参数与能力：`vivix-streaming-avatar-params-2026-10-06.md`
- 主播功夫手册：`live-host-skill-manual-2026-09-24.md`
- Vidu S2 研究：`vidu-s2-research-2026-09-24.md`

**快大脑（2026-10-15，用户：「我之前把事情搞太复杂了」「保留我之前的代码，存档」）**：对照小雅（天工开帧 / Vidu S，弹幕→开口 3.5–5 s、无句间空档）后，新增房间字段 `brain=letta|fast`（后台「大脑方案」）。`fast` = `app/fast_brain.py`（一条弹幕 → 一次 chat-completions ≤8k prompt → 1–3 句 ≤60 字；进场/礼物/「X 说 Y」走模板不进模型）+ `app/live_scheduler.py`（一次一条、礼物优先、播放时预备下一条、20 s 冷场自说、导演队列 `POST /api/characters/live/director`）；场末一次调用写 `data/character-room/fast-summary/<cid>.txt` 作下一场「近况」，Letta 不在热路径。Vivix 另有 `VIVIX_LLM_SHELL=1` 对话模式（Vivix 管线调我们的 `/fast-brain/v1/chat/completions` 壳，`tag_audio` 先「嗯」），协议按 my-avatar playground 格式、待真机验证。旧 Letta 大脑原样保留（`brain=letta` 默认），快照在 `archive/brain-v1-2026-10-15/`、tag `brain-v1-2026-10-15`。设计与对照：`docs/fast-brain-design-2026-10-15.md`、`docs/xiaoya-vs-vivix-comparison-2026-10-15.md`。验收看事件 `fast_metric.reply_ms`。
