Преглед на файлове

feat: skill-mv 纯AI音乐MV全流程技能 v0.1.0

两条产线:
- 产线A 单曲MV:作曲填词→AI音乐→人声分离→关键帧→图生视频→唇形→剪辑合成
- 产线B 口播视频:口播稿→TTS配音→人物讲解→场景切换→成片

设计原则:
- 纯AI生成,不依赖实拍(实拍仅作参考图)
- 可插拔 provider,通道按 available 探活,未开通给清晰指引不伪造
- 明确的降级链:视频→Ken Burns兜底;音乐→失败显式提示

实测状态(2026-10-13):文字/图片/剪辑 3 通道可用;音乐/图生视频/TTS 未开通
产线A 端到端已跑通:18s 成片 1280x720 H.264 含烧字幕

Co-authored-by: FmodeCode <agent@fmode.cn>
FmodeAgent преди 1 седмица
ревизия
1e053c0a5d
променени са 29 файла, в които са добавени 5928 реда и са изтрити 0 реда
  1. 35 0
      .gitignore
  2. 21 0
      LICENSE
  3. 191 0
      README.md
  4. 202 0
      SKILL.md
  5. 151 0
      bin/fmode-mv.js
  6. 459 0
      lib/api.mjs
  7. 164 0
      lib/config.mjs
  8. 87 0
      lib/doctor.mjs
  9. 435 0
      lib/ffmpeg.mjs
  10. 169 0
      lib/index.mjs
  11. 142 0
      lib/prompts.mjs
  12. 437 0
      lib/providers.mjs
  13. 47 0
      package.json
  14. 277 0
      references/capability-matrix.md
  15. 295 0
      references/pipeline-a-song-mv.md
  16. 226 0
      references/pipeline-b-talking.md
  17. 263 0
      references/prompt-templates.md
  18. 308 0
      scripts/assemble.mjs
  19. 177 0
      scripts/keyframe.mjs
  20. 125 0
      scripts/lipsync.mjs
  21. 132 0
      scripts/lyrics.mjs
  22. 147 0
      scripts/music.mjs
  23. 295 0
      scripts/pipeline-a.mjs
  24. 281 0
      scripts/pipeline-b.mjs
  25. 117 0
      scripts/separate.mjs
  26. 295 0
      scripts/storyboard.mjs
  27. 110 0
      scripts/tts.mjs
  28. 230 0
      scripts/video.mjs
  29. 110 0
      templates/storyboard.schema.json

+ 35 - 0
.gitignore

@@ -0,0 +1,35 @@
+node_modules/
+npm-debug.log*
+yarn-error.log*
+.DS_Store
+Thumbs.db
+
+# 产物与工作目录(含生成的音视频,不入库)
+work/
+out/
+dist/
+*.mp4
+*.mp3
+*.wav
+*.flac
+*.m4a
+*.mov
+*.mkv
+*.srt
+*.ass
+*.png
+*.jpg
+*.jpeg
+*.webp
+!templates/**/*.png
+
+# 凭据:绝不入库
+.env
+.env.*
+*.key
+*identity*.json
+fmode-identity.json
+
+# 运行时缓存
+.cache/
+*.log

+ 21 - 0
LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Fmode
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 191 - 0
README.md

@@ -0,0 +1,191 @@
+# fmode-mv
+
+> **纯 AI 音乐 MV 全流程技能** —— 从一句主题到成片,全程零实拍。
+> 两条产线:**AI 单曲 MV** 与 **AI 口播讲解视频**,共用同一套底层模块。
+
+[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
+[![Node](https://img.shields.io/badge/node-%3E%3D18-brightgreen)](https://nodejs.org)
+[![deps](https://img.shields.io/badge/dependencies-0-blue)](./package.json)
+
+---
+
+## 这是什么
+
+把「作曲填词 → AI 生成音乐 → AI 制作 MV」整条链路固化成一个**可复用、可照跑**的技能仓库。
+任意数字生命克隆本仓即可开工:
+
+```bash
+git clone https://git.fmode.cn/fmode-13177980648/skill-mv.git
+cd skill-mv
+node bin/fmode-mv.js doctor       # 环境自检
+node bin/fmode-mv.js providers    # 看哪条通道能用
+node bin/fmode-mv.js song "深夜末班地铁上的城市白领"   # 端到端跑一条 MV
+```
+
+**纯 AI 生成,不依赖实拍** —— 现实拍摄素材最多作参考图/参考音。
+
+---
+
+## 快速开始
+
+```bash
+# 1. 依赖:Node ≥ 18 + ffmpeg(零 npm 依赖)
+node -v && ffmpeg -version | head -1
+
+# 2. 凭据(三选一,绝不硬编码)
+export FEME_API_TOKEN=<你的 token>
+# 或写入 /opt/data/fmode-identity.json 的 newapi_token 字段
+
+# 3. 自检
+node bin/fmode-mv.js doctor
+
+# 4. 产线A:AI 单曲 MV
+node bin/fmode-mv.js song "深夜末班地铁上的城市白领" --genre "city pop" --mood "孤独但温暖"
+
+# 5. 产线B:AI 口播讲解视频
+node bin/fmode-mv.js talk "三分钟讲清楚什么是多租户数据隔离" --duration 90
+```
+
+装成全局命令(可选):
+
+```bash
+npm link
+fmode-mv song "主题"
+```
+
+---
+
+## 两条产线
+
+两条产线**共用同一套底层模块**(图像生成 / 视频生成 / 音频处理 / 剪辑 / provider 接口),
+只是编排不同。
+
+### 产线 A · AI 单曲 MV
+
+```
+歌曲主题 → 作曲填词 → AI 音乐 → 人声/伴奏分离 → 场景&人物关键帧
+        → 图生视频(B-roll 空镜 + 人物演唱镜头,每段 5~8 秒)
+        → 唇形驱动 → 剪辑合成(卡点 + 字幕)→ out/mv.mp4
+```
+
+### 产线 B · AI 口播讲解视频
+
+```
+口播需求 → 口播稿 → TTS 配音 → 讲解画面 → 场景切换 B-roll
+        → 剪辑合成 → out/talk.mp4
+```
+
+---
+
+## ★能力现状(实测,务必先看)
+
+| 环节 | 现状 | 说明 |
+|---|---|---|
+| 作曲填词 / 分镜 / 口播稿 | ✅ **可用** | `POST /v1/chat/completions` |
+| 场景 / 人物关键帧 | ✅ **可用** | `POST /v1/images/generations`(`gpt-image-2.5-sunburst`) |
+| 剪辑 / 合成 | ✅ **可用** | 本地 ffmpeg:拼接/混音/字幕/卡点/响度归一化 |
+| AI 音乐 | ⚠️ **端点存在,渠道未开通** | `/suno/submit/music` 返回 503 |
+| 图生视频 | ⚠️ **无可用渠道** | `/v1/video/generations` 返回 503 |
+| 唇形驱动 | ⚠️ **平台暂无通道** | 留 provider 接口 + 外部对接说明 |
+| TTS 口播配音 | ⚠️ **平台暂无通道** | **本地 edge-tts 可替代**(自动探测) |
+| 音轨分离 | ◐ **ffmpeg 近似 / demucs 可选** | 需要真·分离请装 demucs |
+
+```bash
+fmode-mv providers          # 随时查看最新状态
+fmode-mv providers --deep   # 深度探活(会真实提交,可能计费)
+```
+
+### 未开通通道怎么处理
+
+**绝不静默失败,绝不编造产物。**
+
+- 调用未开通通道 → 抛出 `ProviderUnavailableError`,打印**可操作方案**。
+- 端到端编排遇到未开通步骤 → **如实标记**,能兜底的才兜底,最终报告里逐条列出。
+- 图生视频未开通 → 自动降级为「关键帧 + Ken Burns 运镜」,
+  产物**文件名带 `.fallback`**,报告里明说「N 段为兜底运镜(非真 AI 图生视频)」。
+
+替代路线速查:
+
+| 缺失能力 | 立即可用的替代 |
+|---|---|
+| AI 音乐 | `--music your-song.mp3` 自备音频 |
+| 图生视频 | 自动 Ken Burns 兜底;或 `FEME_VIDEO_MODEL` 换已开通模型 |
+| 唇形驱动 | 自建 Wav2Lip/SadTalker + `FEME_LIPSYNC_ENDPOINT`;或改用表情微动镜头;或纯空镜+字幕 |
+| TTS | `pip install edge-tts`(免费本地,自动探测);或 `FEME_TTS_ENDPOINT` |
+
+详见 [`references/capability-matrix.md`](./references/capability-matrix.md)。
+
+---
+
+## 命令速查
+
+| 命令 | 作用 | 通道 |
+|---|---|---|
+| `fmode-mv song "主题"` | 产线A 端到端 | 全链路 |
+| `fmode-mv talk "主题"` | 产线B 端到端 | 全链路 |
+| `fmode-mv lyrics "主题"` | 作曲填词 | chat |
+| `fmode-mv storyboard --from work/song.json` | 分镜脚本 | chat |
+| `fmode-mv music --from work/song.json` | AI 音乐 | music |
+| `fmode-mv separate <audio>` | 人声/伴奏分离 | 本地 |
+| `fmode-mv keyframe --from work/storyboard.json` | 关键帧 | image |
+| `fmode-mv video --from work/storyboard.json` | 图生视频 | video |
+| `fmode-mv lipsync --probe` | 唇形驱动 | lipsync |
+| `fmode-mv tts --from work/script.md` | 口播配音 | tts |
+| `fmode-mv assemble --from work/storyboard.json --music a.mp3` | 剪辑成片 | 本地 |
+| `fmode-mv providers [--deep]` | 能力现状表 | — |
+| `fmode-mv doctor` | 环境自检 | — |
+
+每个命令都支持 `--help`。
+
+**省钱三招**:`--dry-run` 先看 prompt、`--limit 2` 先跑通全链路、产物在 `work/` 只重跑不满意的单步。
+
+---
+
+## 文档索引
+
+| 文件 | 内容 |
+|---|---|
+| [`SKILL.md`](./SKILL.md) | 技能主文档(设计要点、工作流、产物结构) |
+| [`references/pipeline-a-song-mv.md`](./references/pipeline-a-song-mv.md) | 产线A 完整 SOP(逐步命令 + 避坑) |
+| [`references/pipeline-b-talking.md`](./references/pipeline-b-talking.md) | 产线B 完整 SOP |
+| [`references/prompt-templates.md`](./references/prompt-templates.md) | 提示词模板库(歌词/分镜/人物一致性/空镜/口播) |
+| [`references/capability-matrix.md`](./references/capability-matrix.md) | ★能力现状表与替代方案 |
+| [`templates/storyboard.schema.json`](./templates/storyboard.schema.json) | 分镜数据结构 |
+
+---
+
+## 设计要点
+
+1. **provider 可插拔** —— `lib/providers.mjs` 是唯一通道注册表,模型名与端点全部可被
+   环境变量或身份文件覆盖,换通道**不用改代码**。
+2. **人物一致性靠锚点** —— 分镜里的 `appearanceAnchor` 被逐镜头复述进每张关键帧 prompt。
+   这是纯 AI 出片最容易翻车的地方。
+3. **卡点纯 Node 实现** —— 解码 PCM → 短时能量 → 局部阈值峰值 → 中位间隔估 BPM,
+   不依赖 aubio,零 npm 依赖。
+4. **兜底必须留痕** —— 所有降级产物带 `.fallback` 后缀或 `status: fallback`,
+   用户永远能分清哪些是真 AI 生成、哪些是兜底替代。
+5. **零凭据落盘** —— token 只从环境变量/身份文件读,绝不写入任何文件、日志、产物或提交;
+   对外只输出脱敏形式。
+
+---
+
+## 环境要求
+
+- **Node ≥ 18**(使用内置 `fetch`,**零 npm 依赖**)
+- **ffmpeg / ffprobe**(`apt-get install -y ffmpeg` / `brew install ffmpeg`)
+- **凭据**(优先级:`FEME_*` 环境变量 > `/opt/data/fmode-identity.json` > 兼容旧名 `FMODE_*`)
+- 可选:`demucs`(高质量音轨分离)、`edge-tts`(免费 TTS)、`fonts-noto-cjk`(中文字幕字体)
+
+```bash
+fmode-mv doctor    # 一次查完全部依赖
+```
+
+---
+
+## 许可
+
+[MIT](./LICENSE) © Fmode
+
+---
+
+**FmodeCode / FmodeAgent** · v0.1.0

+ 202 - 0
SKILL.md

@@ -0,0 +1,202 @@
+---
+name: fmode-mv
+description: "触发词:做MV/做音乐视频/写歌并出片/口播讲解视频/AI音乐/AI视频。两条产线:AI单曲MV(作曲填词→音乐→关键帧→图生视频→唇形→成片)与AI口播讲解(口播稿→TTS→讲解画面→B-roll→成片)。纯ESM零依赖,provider 可插拔。"
+version: 0.1.0
+author: Fmode (FmodeAgent)
+license: MIT
+platforms: [linux, macos]
+tags: [mv, music, video, ai, ffmpeg, lyrics, storyboard, lipsync, tts, suno]
+---
+
+# fmode-mv · SKILL
+
+> 纯 AI 音乐 MV 全流程技能。从一句主题到成片,全程零实拍。
+
+## 一、这是什么
+
+把「作曲填词 → AI 生成音乐 → AI 制作 MV」整条链路固化成一个**可复用的技能仓库**。
+任何数字生命克隆本仓即可照跑:
+
+```
+git clone https://git.fmode.cn/fmode-13177980648/skill-mv.git
+cd skill-mv
+node bin/fmode-mv.js doctor      # 自检
+node bin/fmode-mv.js providers   # 看通道现状
+node bin/fmode-mv.js song "主题"  # 端到端跑一条 MV
+```
+
+**纯 AI 生成,不依赖实拍**(现实拍摄素材最多作参考图/参考音)。
+
+## 二、两条产线
+
+两条产线**共用同一套底层模块**(图像 / 视频 / 音频 / 剪辑 / provider 接口),只是编排不同。
+
+### 产线 A · AI 单曲 MV
+
+```
+歌曲主题
+  → 作曲填词          [chat ✅]
+  → AI 生成音乐       [music ⚠️]
+  → 人声/伴奏分离      [local ◐]
+  → 场景&人物关键帧    [image ✅]
+  → 图生视频          [video ⚠️]
+     ├ B-roll 空镜(每段 5~8 秒)
+     └ 人物演唱镜头(每段 5~8 秒)
+  → 唇形驱动          [lipsync ⚠️]
+  → 剪辑合成(卡点+字幕+混音)[local ffmpeg ✅]
+  → out/mv.mp4
+```
+
+```bash
+fmode-mv song "深夜末班地铁上的城市白领" --genre "city pop" --mood "孤独但温暖"
+```
+
+### 产线 B · AI 口播讲解视频
+
+```
+口播需求
+  → 口播稿            [chat ✅]
+  → TTS 配音          [tts ⚠️]
+  → 人物讲解画面       [image ✅]
+  → 场景切换 B-roll    [video ⚠️]
+  → 剪辑合成          [local ffmpeg ✅]
+  → out/talk.mp4
+```
+
+```bash
+fmode-mv talk "三分钟讲清楚什么是多租户数据隔离" --duration 90
+```
+
+## 三、★能力现状(实测,务必据此预期)
+
+| 环节 | 端点 | 现状 | 说明 |
+|---|---|---|---|
+| 作曲填词 / 分镜 / 口播稿 | `POST /v1/chat/completions` | ✅ **可用** | `deepseek-v4-flash`、`doubao-seed-2-1-pro` 等;实测出词正常 |
+| 场景 / 人物关键帧 | `POST /v1/images/generations` | ✅ **可用** | `gpt-image-2.5-sunburst`,返回 b64_json |
+| AI 音乐 | `POST /suno/submit/music` + `POST /suno/fetch` | ⚠️ **端点存在,渠道未开通** | 实测提交返回 503 `Task request failed` |
+| 图生视频 | `POST /v1/video/generations` | ⚠️ **无可用渠道** | 实测 503 `No available channel for model doubao-seedance-1-0-pro` |
+| 唇形驱动 | — | ⚠️ **平台暂无通道** | 网关无该路由(返回前端兜底页) |
+| TTS 口播配音 | `/v1/audio/speech` | ⚠️ **平台暂无通道** | 网关无该路由;**本地装 edge-tts 即可用** |
+| 音轨分离 | 本地 | ◐ ffmpeg 相位兜底 / demucs 可选 | 需要真·分离请装 demucs |
+| 剪辑 / 合成 | 本地 ffmpeg | ✅ **可用** | 拼接 / 混音 / 字幕 / 卡点 / 响度归一化 |
+
+随时用 `fmode-mv providers`(加 `--deep` 做真实提交探活)查看最新状态。
+
+### 未开通通道怎么处理 —— 本技能的承诺
+
+**绝不静默失败,绝不编造产物。**
+
+- 调用未开通通道 → 抛 `ProviderUnavailableError`,打印**可操作方案**(换通道 / 对接外部服务 / 用现成素材)。
+- 产线编排里遇到未开通步骤 → **如实标记 `unavailable`**,能兜底的才兜底,并在最终报告中标出。
+- 图生视频未开通 → 自动降级为「关键帧 + Ken Burns 运镜」,产物**文件名带 `.fallback`**,
+  报告里写明「N 段为兜底运镜(非真 AI 图生视频)」。
+
+替代路线(详见 `references/capability-matrix.md`):
+
+| 缺失能力 | 立即可用的替代 |
+|---|---|
+| AI 音乐 | 自备音频 `--music your-song.mp3`;等渠道开通后原样重跑 |
+| 图生视频 | 自动 Ken Burns 兜底,先出片后替换;或 `FEME_VIDEO_MODEL` 换已开通模型 |
+| 唇形驱动 | 自建 Wav2Lip/SadTalker + `FEME_LIPSYNC_ENDPOINT`;或改用表情微动镜头;或纯空镜+字幕 |
+| TTS | `pip install edge-tts`(免费本地,本技能自动探测);或 `FEME_TTS_ENDPOINT` 对接外部服务 |
+
+## 四、安装
+
+```bash
+git clone https://git.fmode.cn/fmode-13177980648/skill-mv.git
+cd skill-mv
+npm link            # 可选:全局获得 fmode-mv 命令
+fmode-mv doctor
+```
+
+**依赖**:
+
+- Node ≥ 18(用内置 `fetch`,**零 npm 依赖**)
+- `ffmpeg` / `ffprobe`(`apt-get install -y ffmpeg`)
+- 凭据(三选一,见下)
+- 可选:`demucs`(高质量音轨分离)、`edge-tts`(免费 TTS)
+
+### 凭据(零硬编码)
+
+按优先级读取:
+
+1. 环境变量 `FEME_API_TOKEN` / `FEME_API_KEY` / `FEME_API_BASE_URL`
+2. `/opt/data/fmode-identity.json` 的 `newapi_token` 字段
+3. 兼容旧名 `FMODE_API_KEY` / `FMODE_API_BASE_URL`
+
+```bash
+export FEME_API_TOKEN=<你的token>
+# 或写入身份文件(不要提交进 Git)
+```
+
+> 本技能**不会**把 token 写入任何文件、日志、产物或提交。
+
+## 五、命令速查
+
+| 命令 | 作用 | 通道 |
+|---|---|---|
+| `fmode-mv song "主题"` | 产线A 端到端 | 全链路 |
+| `fmode-mv talk "主题"` | 产线B 端到端 | 全链路 |
+| `fmode-mv lyrics "主题"` | 作曲填词 | chat |
+| `fmode-mv storyboard --from work/song.json` | 分镜脚本 | chat |
+| `fmode-mv music --from work/song.json` | AI 音乐 | music |
+| `fmode-mv separate <audio>` | 人声/伴奏分离 | 本地 |
+| `fmode-mv keyframe --from work/storyboard.json` | 关键帧 | image |
+| `fmode-mv video --from work/storyboard.json` | 图生视频 | video |
+| `fmode-mv lipsync --probe` | 唇形驱动 | lipsync |
+| `fmode-mv tts --from work/script.md` | 口播配音 | tts |
+| `fmode-mv assemble --from work/storyboard.json --music a.mp3` | 剪辑成片 | 本地 |
+| `fmode-mv providers [--deep]` | 能力现状表 | — |
+| `fmode-mv doctor` | 环境自检 | — |
+| `fmode-mv version --check` | 三处版本一致性 | — |
+
+每个命令都支持 `--help` 查看完整参数。
+
+## 六、工作流与产物
+
+```
+work/                       中间产物(.gitignore 已排除)
+├── song.json               作曲填词结果(歌词/曲风/结构/风格prompt)
+├── storyboard.json         ★分镜(下游全部消费它)
+├── storyboard.md           人读版分镜表
+├── script.json / .md       产线B 口播稿
+├── audio/                  音乐、人声、伴奏、配音
+├── frames/shot-XX.png      关键帧
+├── clips/shot-XX.mp4       视频片段(兜底产物为 shot-XX.fallback.mp4)
+├── lipsync/
+└── subtitles.srt
+out/
+├── mv.mp4 / talk.mp4       成片
+└── manifest.json           成片清单(含兜底片段数、BPM、音轨)
+```
+
+分镜结构见 `templates/storyboard.schema.json`。
+
+## 七、两条产线完整 SOP
+
+- 产线A:`references/pipeline-a-song-mv.md`(逐步命令 + 避坑)
+- 产线B:`references/pipeline-b-talking.md`(逐步命令 + 避坑)
+- 提示词模板库:`references/prompt-templates.md`
+- 能力矩阵与替代方案:`references/capability-matrix.md`
+
+## 八、设计要点(改代码前先看)
+
+1. **provider 可插拔**:`lib/providers.mjs` 是唯一通道注册表。换模型/换端点只需:
+   ```bash
+   export FEME_MUSIC_MODEL=xxx      # 或 FEME_MUSIC_ENDPOINT / FEME_VIDEO_MODEL ...
+   ```
+   或在 `fmode-identity.json` 写 `providers: { music: { model, endpoint } }`。**无需改代码**。
+2. **人物一致性靠锚点**:分镜里的 `appearanceAnchor` 会被逐镜头复述进 `imagePrompt`。
+   这是纯 AI 出片最容易翻车的地方,改动分镜逻辑时不要删掉它。
+3. **卡点用能量包络**:`lib/ffmpeg.mjs` 的 `detectBeats()` 纯 Node 实现(解码 PCM → 短时能量 → 局部阈值峰值 → 中位间隔估 BPM),
+   不依赖 aubio。`assemble --beat-sync` 会把镜头时长吸附到节拍点。
+4. **兜底必须留痕**:所有降级产物都带 `.fallback` 后缀或 `status: fallback`,报告里明说。
+   这是为了「绝不伪造产物」——用户永远能分清哪些是真 AI 生成、哪些是兜底。
+
+## 九、许可
+
+MIT,见 `LICENSE`。
+
+---
+
+**FmodeCode / FmodeAgent** · v0.1.0

+ 151 - 0
bin/fmode-mv.js

@@ -0,0 +1,151 @@
+#!/usr/bin/env node
+// bin/fmode-mv.js — fmode-mv CLI 入口
+//
+// 所有子命令都是薄封装:真正逻辑在 scripts/*.mjs 与 lib/*.mjs。
+
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const ROOT = join(__dirname, '..');
+
+const HELP = `
+fmode-mv — 纯 AI 音乐 MV 全流程技能(零依赖 · 纯 ESM)
+
+用法:
+  fmode-mv <命令> [参数]
+
+两条产线:
+  song       产线A:AI 单曲 MV 端到端编排
+             主题 → 作曲填词 → AI音乐 → 音轨分离 → 关键帧 → 图生视频 → 唇形 → 剪辑成片
+  talk       产线B:AI 口播讲解视频端到端编排
+             口播稿 → TTS 配音 → 讲解画面 → 场景 B-roll → 剪辑成片
+
+分步命令(各自可单独跑,产物落 work/):
+  lyrics     作曲填词:主题 → 歌词 + 曲风 prompt + 结构标记        [chat]
+  storyboard 分镜脚本:歌词/口播稿 → 每镜头时间码/画面/prompt      [chat]
+  music      AI 音乐生成                                          [music]
+  separate   人声/伴奏分离(ffmpeg 兜底 + demucs 可选)           [local]
+  keyframe   场景 & 人物关键帧                                     [image]
+  video      图生视频(B-roll 空镜 / 人物演唱镜头)                [video]
+  lipsync    唇形驱动                                             [lipsync]
+  tts        口播配音                                             [tts]
+  assemble   剪辑合成成片(卡点 + 字幕 + 混音)                    [local ffmpeg]
+
+诊断:
+  providers  打印能力现状表(--deep 做真实探活)
+  doctor     环境自检:node / ffmpeg / 凭据 / 各通道
+  version    版本号(--check 校验三处一致)
+
+通用参数:
+  --out <dir>      指定工作目录(默认 ./work)
+  --json           只输出 JSON(便于程序消费)
+  --help, -h       显示帮助
+
+示例:
+  fmode-mv lyrics "离别与重逢" --genre "city pop" --style 温暖
+  fmode-mv keyframe --from work/storyboard.json --shot 1
+  fmode-mv song "城市夜晚的孤独" --title "夜行" --limit 6
+  fmode-mv talk --script work/script.md
+  fmode-mv providers --deep
+`;
+
+const COMMANDS = {
+  song: { file: 'pipeline-a.mjs', desc: '产线A:AI 单曲 MV 端到端' },
+  talk: { file: 'pipeline-b.mjs', desc: '产线B:AI 口播讲解视频端到端' },
+  lyrics: { file: 'lyrics.mjs', desc: '作曲填词' },
+  storyboard: { file: 'storyboard.mjs', desc: '分镜脚本' },
+  music: { file: 'music.mjs', desc: 'AI 音乐生成' },
+  separate: { file: 'separate.mjs', desc: '人声/伴奏分离' },
+  keyframe: { file: 'keyframe.mjs', desc: '关键帧生成' },
+  video: { file: 'video.mjs', desc: '图生视频' },
+  lipsync: { file: 'lipsync.mjs', desc: '唇形驱动' },
+  tts: { file: 'tts.mjs', desc: '口播配音' },
+  assemble: { file: 'assemble.mjs', desc: '剪辑合成成片' },
+};
+
+async function main() {
+  const argv = process.argv.slice(2);
+  const cmd = argv[0];
+
+  if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') {
+    console.log(HELP);
+    return 0;
+  }
+
+  if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
+    const { VERSION, checkVersionConsistency } = await import(join(ROOT, 'lib', 'index.mjs'));
+    if (argv.includes('--check')) {
+      const r = checkVersionConsistency();
+      console.log(`index.mjs  : ${r.version}`);
+      console.log(`package    : ${r.pkgVersion}`);
+      console.log(`SKILL.md   : ${r.skillVersion}`);
+      if (r.ok) {
+        console.log('✓ 三处版本一致');
+        return 0;
+      }
+      for (const i of r.issues) console.error(`✗ ${i}`);
+      return 1;
+    }
+    console.log(VERSION);
+    return 0;
+  }
+
+  if (cmd === 'providers') {
+    const { probeAll, PROVIDERS, KINDS } = await import(join(ROOT, 'lib', 'providers.mjs'));
+    const { loadConfig } = await import(join(ROOT, 'lib', 'config.mjs'));
+    const deep = argv.includes('--deep');
+    console.log(`\n网关: ${loadConfig().baseUrl}`);
+    console.log(deep ? '模式: 深度探活(会真实提交)\n' : '模式: 轻量探活(不做真实提交)\n');
+    const rows = await probeAll({ deep });
+    const w = Math.max(...rows.map((r) => r.kind.length));
+    for (const r of rows) {
+      const mark = r.available === true ? '✅ 可用' : r.available === null ? '◐ 未知' : '⚠️  未开通';
+      console.log(`${r.kind.padEnd(w)}  ${mark}  ${r.label}`);
+      if (r.model) console.log(`${' '.repeat(w)}      模型: ${r.model}`);
+      console.log(`${' '.repeat(w)}      现状: ${r.detail}`);
+      if (r.hint) console.log(`${' '.repeat(w)}      建议: ${String(r.hint).split('\n')[0]}`);
+      console.log('');
+    }
+    const okCount = rows.filter((r) => r.available === true).length;
+    console.log(`汇总: ${okCount}/${rows.length} 通道可用`);
+    return 0;
+  }
+
+  if (cmd === 'doctor') {
+    const { runDoctor } = await import(join(ROOT, 'lib', 'doctor.mjs'));
+    return runDoctor(argv.slice(1));
+  }
+
+  const spec = COMMANDS[cmd];
+  if (!spec) {
+    console.error(`未知命令: ${cmd}\n`);
+    console.log(HELP);
+    return 1;
+  }
+
+  const mod = await import(join(ROOT, 'scripts', spec.file));
+  if (typeof mod.main !== 'function') {
+    console.error(`内部错误: scripts/${spec.file} 未导出 main()`);
+    return 1;
+  }
+  try {
+    return (await mod.main(argv.slice(1))) ?? 0;
+  } catch (e) {
+    const { reportUnavailable, log } = await import(join(ROOT, 'lib', 'index.mjs'));
+    if (e?.name === 'ProviderUnavailableError') {
+      reportUnavailable(spec.desc, e);
+      return 3;
+    }
+    log.err(e?.message || String(e));
+    if (process.env.DEBUG) console.error(e.stack);
+    return 1;
+  }
+}
+
+main()
+  .then((code) => process.exit(Number(code) || 0))
+  .catch((e) => {
+    console.error('致命错误:', e?.stack || e?.message || e);
+    process.exit(1);
+  });

+ 459 - 0
lib/api.mjs

@@ -0,0 +1,459 @@
+// lib/api.mjs — 网关 HTTP 封装(chat / images / 异步 task 轮询 / 文件下载)
+// 零依赖,仅用 Node 内置 fetch(Node >= 18)。
+
+import { loadConfig, requireToken, mask } from './config.mjs';
+
+/** 通道未开通/上游不可用时抛出,携带可操作指引 */
+export class ProviderUnavailableError extends Error {
+  constructor(message, { endpoint, status, hint, cause } = {}) {
+    super(message);
+    this.name = 'ProviderUnavailableError';
+    this.endpoint = endpoint;
+    this.status = status;
+    this.hint = hint;
+    if (cause) this.cause = cause;
+  }
+}
+
+/** 网关返回了业务错误(参数错/额度等) */
+export class GatewayError extends Error {
+  constructor(message, { endpoint, status, body } = {}) {
+    super(message);
+    this.name = 'GatewayError';
+    this.endpoint = endpoint;
+    this.status = status;
+    this.body = body;
+  }
+}
+
+const DEFAULT_TIMEOUT = 120_000;
+
+function joinUrl(base, path) {
+  return `${String(base).replace(/\/+$/, '')}/${String(path).replace(/^\/+/, '')}`;
+}
+
+/**
+ * 底层请求。自动带 token、超时、把网关错误翻译成可读异常。
+ * @returns {Promise<{status:number, headers:Headers, body:any, text:string}>}
+ */
+export async function request(path, opts = {}) {
+  const {
+    method = 'GET',
+    body,
+    timeout = DEFAULT_TIMEOUT,
+    base, // 覆盖 base(默认 config.baseUrl)
+    headers = {},
+    expectJson = true,
+    raw = false,
+    retries = 0,
+    retryDelay = 1500,
+  } = opts;
+
+  const cfg = loadConfig();
+  const token = requireToken();
+  const url = /^https?:\/\//.test(path) ? path : joinUrl(base || cfg.baseUrl, path);
+
+  let lastErr;
+  for (let attempt = 0; attempt <= retries; attempt++) {
+    if (attempt > 0) {
+      await sleep(retryDelay * 2 ** (attempt - 1));
+    }
+    try {
+      const res = await fetch(url, {
+        method,
+        headers: {
+          Authorization: `Bearer ${token}`,
+          Accept: 'application/json',
+          ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
+          ...headers,
+        },
+        body: body !== undefined ? (typeof body === 'string' ? body : JSON.stringify(body)) : undefined,
+        signal: AbortSignal.timeout(timeout),
+      });
+
+      const text = raw ? '' : await res.text();
+      let parsed = null;
+      if (expectJson && text) {
+        try {
+          parsed = JSON.parse(text);
+        } catch {
+          parsed = null;
+        }
+      }
+
+      if (!res.ok) {
+        const err = classifyHttpError(res.status, text, url, parsed);
+        // 5xx / 429 可重试
+        if ((res.status >= 500 || res.status === 429) && attempt < retries) {
+          lastErr = err;
+          continue;
+        }
+        throw err;
+      }
+      return { status: res.status, headers: res.headers, body: parsed, text };
+    } catch (e) {
+      if (e instanceof ProviderUnavailableError || e instanceof GatewayError) throw e;
+      lastErr = new GatewayError(`${e.name === 'TimeoutError' ? '请求超时' : '网络错误'}: ${e.message}`, {
+        endpoint: url,
+      });
+      if (attempt >= retries) throw lastErr;
+    }
+  }
+  throw lastErr;
+}
+
+/** 把 HTTP 状态码 / 网关错误体翻译成「能照着做」的错误 */
+function classifyHttpError(status, text, url, parsed) {
+  const raw = (parsed && (parsed.error?.message || parsed.message)) || text.slice(0, 300);
+  const lower = String(raw).toLowerCase();
+
+  // 渠道未开通的典型特征
+  const unavailable =
+    lower.includes('no available channel') ||
+    lower.includes('model_not_found') ||
+    lower.includes('channel') && lower.includes('not') ||
+    lower.includes('task request failed') ||
+    lower.includes('upstream') && (status === 404 || status === 502 || status === 503) ||
+    lower.includes('not implemented');
+
+  const hint =
+    status === 401 || status === 403
+      ? '凭据无效或权限不足:检查 FEME_API_TOKEN / /opt/data/fmode-identity.json 的 newapi_token'
+      : unavailable
+        ? '该模型/通道在当前网关未开通。可用 `fmode-mv providers` 查看替代通道,或在 provider 配置里改换模型名。'
+        : status === 429
+          ? '触发限流,稍后重试或降低并发'
+          : undefined;
+
+  const message = `网关 ${status} @ ${url}\n  ${raw}` + (hint ? `\n  → ${hint}` : '');
+
+  if (unavailable) {
+    return new ProviderUnavailableError(message, { endpoint: url, status, hint });
+  }
+  return new GatewayError(message, { endpoint: url, status, body: raw });
+}
+
+export function sleep(ms) {
+  return new Promise((r) => setTimeout(r, ms));
+}
+
+/** 下载远程文件为 Buffer */
+export async function download(url, { timeout = 180_000 } = {}) {
+  const res = await fetch(url, { signal: AbortSignal.timeout(timeout) });
+  if (!res.ok) throw new GatewayError(`下载失败 ${res.status}: ${url}`);
+  return Buffer.from(await res.arrayBuffer());
+}
+
+// ==================== Chat ====================
+
+/**
+ * 文本对话(作曲填词 / 分镜 / 口播稿都走这里)。
+ * @param {{model?:string, messages:Array, system?:string, temperature?:number, max_tokens?:number, json?:boolean, timeout?:number}} o
+ */
+export async function chat(o = {}) {
+  const {
+    model = process.env.FEME_CHAT_MODEL || 'deepseek-v4-flash',
+    messages = [],
+    system,
+    temperature = 0.8,
+    max_tokens = 4096,
+    json = false,
+    timeout = 180_000,
+    retries = 1,
+  } = o;
+
+  const msgs = system ? [{ role: 'system', content: system }, ...messages] : messages;
+
+  const body = {
+    model,
+    messages: msgs,
+    temperature,
+    max_tokens,
+    stream: false,
+  };
+  if (json) body.response_format = { type: 'json_object' };
+
+  const res = await request('/chat/completions', {
+    method: 'POST',
+    body,
+    timeout,
+    retries,
+  });
+
+  const choice = res.body?.choices?.[0];
+  // 部分推理模型(如 deepseek-v4)会把内容放在 reasoning_content,content 可能为空
+  let content = choice?.message?.content;
+  if ((content == null || content === '') && choice?.message?.reasoning_content) {
+    if (json) {
+      // 尝试从 reasoning 里抠出 JSON
+      const m = String(choice.message.reasoning_content).match(/\{[\s\S]*\}/);
+      content = m ? m[0] : choice.message.reasoning_content;
+    } else {
+      content = choice.message.reasoning_content;
+    }
+  }
+  if (content == null) {
+    throw new GatewayError(`chat 返回为空(model=${model})`, { body: res.body });
+  }
+  return {
+    content: String(content),
+    model: res.body?.model || model,
+    usage: res.body?.usage,
+    finishReason: choice?.finish_reason || null,
+    truncated: choice?.finish_reason === 'length',
+  };
+}
+
+/**
+ * 让模型产出 JSON 并解析。
+ *
+ * 现实里有两类坑,这里都自动处理:
+ *   1) 推理型模型(如 deepseek-v4-flash)会把大量 token 花在推理上,
+ *      导致 max_tokens 被吃满、JSON 被截断 —— finish_reason === 'length'。
+ *      → 自动把 max_tokens 翻倍重试,最多升到上限。
+ *   2) 输出带 markdown 围栏 / 前后废话 / 轻微格式错误。
+ *      → extractJSON 已能剥离围栏;仍失败则带着错误提示让模型重出一次。
+ */
+export async function chatJSON(o = {}) {
+  const {
+    model = process.env.FEME_CHAT_MODEL || 'deepseek-v4-flash',
+    messages = [],
+    system,
+    temperature = 0.7,
+    max_tokens = 4096,
+    maxTokensCap = 32768,
+    escalationSteps = 2,
+    repairAttempts = 1,
+    timeout = 300_000,
+  } = o;
+
+  const msgs = system ? [{ role: 'system', content: system }, ...messages] : [...messages];
+  let budget = max_tokens;
+  let lastText = '';
+  let lastErr = null;
+
+  for (let step = 0; step <= escalationSteps; step++) {
+    const r = await chat({ model, messages: msgs, temperature, max_tokens: budget, json: true, timeout });
+    lastText = r.content;
+    const parsed = extractJSON(r.content);
+    if (parsed !== null) return { ...r, json: parsed, maxTokensUsed: budget };
+
+    // 截断 → 加大预算重来
+    if (r.truncated && budget < maxTokensCap) {
+      budget = Math.min(maxTokensCap, budget * 2);
+      continue;
+    }
+    lastErr = r;
+    break;
+  }
+
+  // 修复式重试:把失败输出回喂,要求只吐合法 JSON
+  for (let i = 0; i < repairAttempts; i++) {
+    const tail = lastText.slice(-1200);
+    const r = await chat({
+      model,
+      messages: [
+        ...msgs,
+        { role: 'assistant', content: tail },
+        {
+          role: 'user',
+          content:
+            '上面的 JSON 不完整或格式非法,无法解析。请重新输出**完整且合法**的 JSON:' +
+            '不要省略任何数组元素、不要写 markdown 围栏、不要在 JSON 前后添加任何解释文字。' +
+            '如果内容太长,请压缩每个字段的文字长度,但必须保证 JSON 结构完整闭合。',
+        },
+      ],
+      temperature: 0.3,
+      max_tokens: maxTokensCap,
+      json: true,
+      timeout,
+    });
+    lastText = r.content;
+    const parsed = extractJSON(r.content);
+    if (parsed !== null) return { ...r, json: parsed, maxTokensUsed: maxTokensCap, repaired: true };
+  }
+
+  const hint =
+    lastErr?.truncated || lastText.length > 3000
+      ? '\n  → 疑似输出被 max_tokens 截断。可提高 --max-tokens,或减少镜头数(--shots),或换用非推理型模型(FEME_CHAT_MODEL=doubao-seed-2-1-pro)。'
+      : '';
+  throw new GatewayError(
+    `模型未返回可解析的 JSON(model=${model},已尝试升配与修复)。输出尾部片段:\n${lastText.slice(-400)}${hint}`
+  );
+}
+
+/** 从可能带 ```json 围栏或前后缀说明的文本中抠出 JSON */
+export function extractJSON(text) {
+  if (!text) return null;
+  const s = String(text).trim();
+  const tryParse = (t) => {
+    try {
+      return JSON.parse(t);
+    } catch {
+      return null;
+    }
+  };
+  let v = tryParse(s);
+  if (v !== null) return v;
+
+  const fence = s.match(/```(?:json)?\s*([\s\S]*?)```/i);
+  if (fence) {
+    v = tryParse(fence[1].trim());
+    if (v !== null) return v;
+  }
+  const obj = s.match(/[{[][\s\S]*[}\]]/);
+  if (obj) {
+    v = tryParse(obj[0]);
+    if (v !== null) return v;
+  }
+  return null;
+}
+
+// ==================== Images ====================
+
+/**
+ * 文生图 / 图生图(关键帧)。
+ * @param {{prompt:string, model?:string, size?:string, n?:number, refImage?:string|Buffer, timeout?:number}} o
+ * @returns {Promise<{buffers:Buffer[], model:string, size:string}>}
+ */
+export async function generateImage(o = {}) {
+  const {
+    prompt,
+    model = process.env.FEME_IMAGE_MODEL || 'gpt-image-2.5-sunburst',
+    size = '1024x1024',
+    n = 1,
+    refImage, // 参考图(URL 或本地路径/base64)—— 用于人物一致性
+    timeout = 300_000,
+    retries = 1,
+  } = o;
+  if (!prompt) throw new GatewayError('generateImage 需要 prompt');
+
+  const payload = { model, prompt, n, size };
+  if (refImage) payload.image = await toImageInput(refImage); // 兼容 OpenAI 风格图生图
+
+  const res = await request('/images/generations', {
+    method: 'POST',
+    body: payload,
+    timeout,
+    retries,
+  });
+
+  const items = res.body?.data || [];
+  const buffers = [];
+  for (const it of items) {
+    if (it.b64_json) buffers.push(Buffer.from(it.b64_json, 'base64'));
+    else if (it.url) buffers.push(await download(it.url));
+  }
+  if (!buffers.length) {
+    throw new GatewayError(`图片通道未返回图像数据(model=${model})`, { body: JSON.stringify(res.body).slice(0, 300) });
+  }
+  return { buffers, model: res.body?.model || model, size };
+}
+
+async function toImageInput(ref) {
+  if (Buffer.isBuffer(ref)) return `data:image/png;base64,${ref.toString('base64')}`;
+  const s = String(ref);
+  if (/^https?:\/\//.test(s) || s.startsWith('data:')) return s;
+  const { readFileSync } = await import('node:fs');
+  const buf = readFileSync(s);
+  const ext = s.toLowerCase().endsWith('.jpg') || s.toLowerCase().endsWith('.jpeg') ? 'jpeg' : 'png';
+  return `data:image/${ext};base64,${buf.toString('base64')}`;
+}
+
+// ==================== 异步任务(音乐 / 视频 / 唇形) ====================
+
+/**
+ * 提交一个异步任务并轮询至完成。
+ * 不同通道的字段名差异用 pick 函数吸收。
+ *
+ * @param {object} o
+ * @param {string} o.submitPath  提交路径
+ * @param {string} o.fetchPath   查询路径(返回单任务)
+ * @param {string} o.listPath    查询路径(返回任务列表,多数通道用这个)
+ * @param {object} o.payload     提交体
+ * @param {number} o.interval    轮询间隔 ms
+ * @param {number} o.maxWait     最长等待 ms
+ * @param {(t:any)=>({status:string, progress?:number, result?:any, error?:string})} o.pick 状态解析
+ */
+export async function runAsyncTask(o) {
+  const {
+    submitPath,
+    fetchPath,
+    listPath,
+    payload,
+    interval = 5000,
+    maxWait = 600_000,
+    pick,
+    submitBase,
+    fetchBase,
+    taskLabel = 'task',
+    onTick,
+  } = o;
+
+  const cfg = loadConfig();
+  const submitRes = await request(submitPath, {
+    method: 'POST',
+    body: payload,
+    base: submitBase || cfg.rootUrl,
+    timeout: 60_000,
+    retries: 0,
+  });
+
+  const taskId =
+    submitRes.body?.data ??
+    submitRes.body?.id ??
+    submitRes.body?.task_id ??
+    submitRes.body?.taskId ??
+    submitRes.body?.data?.task_id ??
+    submitRes.body?.data?.id;
+
+  if (!taskId) {
+    throw new GatewayError(`提交${taskLabel}未返回任务 ID`, {
+      body: JSON.stringify(submitRes.body).slice(0, 300),
+    });
+  }
+
+  const started = Date.now();
+  let last = null;
+  while (Date.now() - started < maxWait) {
+    await sleep(interval);
+    const id = typeof taskId === 'object' ? taskId.id : taskId;
+    const res = listPath
+      ? await request(listPath, {
+          method: 'POST',
+          body: { ids: [id] },
+          base: fetchBase || cfg.rootUrl,
+          timeout: 60_000,
+        })
+      : await request(`${fetchPath}?ids=${encodeURIComponent(id)}`, {
+          base: fetchBase || cfg.rootUrl,
+          timeout: 60_000,
+        });
+
+    const raw = listPath ? res.body?.data?.[0] ?? res.body?.data ?? res.body : res.body?.data ?? res.body;
+    const st = pick ? pick(raw) : { status: normalStatus(raw?.status) };
+    last = st;
+    if (onTick) onTick({ ...st, taskId: id, elapsed: Date.now() - started });
+
+    if (st.status === 'succeeded' || st.status === 'complete' || st.status === 'completed') {
+      return { taskId: id, result: st.result ?? raw, elapsed: Date.now() - started };
+    }
+    if (st.status === 'failed' || st.status === 'error') {
+      throw new GatewayError(`${taskLabel}执行失败:${st.error || '未知原因'}`, {
+        body: JSON.stringify(raw).slice(0, 500),
+      });
+    }
+  }
+  throw new GatewayError(
+    `${taskLabel}超时(${Math.round(maxWait / 1000)}s 未完成,最后状态 ${last?.status || 'unknown'},taskId=${last?.taskId || '-'})`
+  );
+}
+
+function normalStatus(s) {
+  const v = String(s || '').toLowerCase();
+  if (['success', 'succeeded', 'succeed', 'complete', 'completed', 'done'].includes(v)) return 'succeeded';
+  if (['fail', 'failed', 'error', 'cancelled', 'canceled'].includes(v)) return 'failed';
+  if (!v) return 'unknown';
+  return 'running';
+}
+

+ 164 - 0
lib/config.mjs

@@ -0,0 +1,164 @@
+// lib/config.mjs — 身份与凭据解析
+// 优先级:FEME_* 环境变量 > /opt/data/fmode-identity.json > 兼容旧名 FMODE_*
+//
+// 铁律:任何情况下都不打印、不落盘、不提交明文 token。
+// 本模块只提供「取值」与「脱敏显示」,脱敏一律走 mask()。
+
+import { readFileSync, existsSync } from 'node:fs';
+import { homedir } from 'node:os';
+import { join } from 'node:path';
+
+/** 身份文件候选路径(按序探测,取第一个存在的) */
+export const IDENTITY_PATHS = [
+  process.env.FEME_IDENTITY_FILE,
+  process.env.FMODE_IDENTITY_FILE,
+  '/opt/data/fmode-identity.json',
+  join(homedir(), '.fmode', 'identity.json'),
+].filter(Boolean);
+
+/** 网关基础地址候选环境变量(FEME_* 优先) */
+const BASE_ENV_KEYS = [
+  'FEME_API_BASE_URL',
+  'FEME_API_BASE',
+  'FMODE_API_BASE_URL',
+  'FMODE_API_BASE',
+];
+
+/** token 候选环境变量(FEME_* 优先) */
+const TOKEN_ENV_KEYS = [
+  'FEME_API_TOKEN',
+  'FEME_API_KEY',
+  'FEME_TOKEN',
+  'FMODE_API_TOKEN',
+  'FMODE_API_KEY',
+  'FMODE_NEWAPI_TOKEN',
+  'OPENAI_API_KEY',
+];
+
+const DEFAULT_BASE = 'https://api.fmode.cn/v1';
+
+/** 读取身份文件(不存在则返回 null,绝不抛错) */
+function readIdentityFile() {
+  for (const p of IDENTITY_PATHS) {
+    try {
+      if (p && existsSync(p)) {
+        return { path: p, data: JSON.parse(readFileSync(p, 'utf8')) };
+      }
+    } catch {
+      /* 忽略损坏的身份文件,继续找下一个 */
+    }
+  }
+  return { path: null, data: {} };
+}
+
+/** 从环境变量取第一个非空值 */
+function firstEnv(keys) {
+  for (const k of keys) {
+    const v = process.env[k];
+    if (v && String(v).trim() && !String(v).includes('***')) return String(v).trim();
+  }
+  return null;
+}
+
+/** 把 base url 归一化成以 /v1 结尾、无尾斜杠 */
+export function normalizeBaseUrl(url) {
+  let u = String(url || DEFAULT_BASE).trim().replace(/\/+$/, '');
+  if (!/\/v\d+$/.test(u)) u += '/v1';
+  return u;
+}
+
+let _cache = null;
+
+/**
+ * 解析运行配置。结果会被缓存(同一进程内身份不会变)。
+ * @returns {{baseUrl:string, rootUrl:string, token:string|null, tokenSource:string,
+ *            identityPath:string|null, identity:object}}
+ */
+export function loadConfig({ refresh = false } = {}) {
+  if (_cache && !refresh) return _cache;
+
+  const ident = readIdentityFile();
+  const id = ident.data || {};
+
+  // token:FEME_* > 身份文件 > FMODE_*
+  let token = firstEnv(['FEME_API_TOKEN', 'FEME_API_KEY', 'FEME_TOKEN']);
+  let tokenSource = token ? 'env:FEME_*' : null;
+  if (!token) {
+    for (const k of ['newapi_token', 'api_key', 'apiKey', 'token']) {
+      if (id[k] && String(id[k]).trim()) {
+        token = String(id[k]).trim();
+        tokenSource = `identity:${k}`;
+        break;
+      }
+    }
+  }
+  if (!token) {
+    token = firstEnv(['FMODE_API_TOKEN', 'FMODE_API_KEY', 'FMODE_NEWAPI_TOKEN', 'OPENAI_API_KEY']);
+    tokenSource = token ? 'env:FMODE_*' : null;
+  }
+
+  // base url:FEME_* > 身份文件 > FMODE_* > 默认
+  let baseUrl = firstEnv(['FEME_API_BASE_URL', 'FEME_API_BASE']);
+  if (!baseUrl && id.api_base_url) baseUrl = String(id.api_base_url);
+  if (!baseUrl) baseUrl = firstEnv(['FMODE_API_BASE_URL', 'FMODE_API_BASE']);
+  if (!baseUrl) baseUrl = DEFAULT_BASE;
+
+  const normalized = normalizeBaseUrl(baseUrl);
+  const rootUrl = normalized.replace(/\/v\d+$/, '');
+
+  _cache = {
+    baseUrl: normalized,
+    rootUrl,
+    token,
+    tokenSource,
+    identityPath: ident.path,
+    identity: id,
+  };
+  return _cache;
+}
+
+/** 脱敏显示,用于日志/错误信息。永远不要把未脱敏 token 拼进字符串。 */
+export function mask(secret) {
+  const s = String(secret || '');
+  if (!s) return '<empty>';
+  if (s.length <= 8) return s[0] + '*'.repeat(Math.max(1, s.length - 1));
+  return `${s.slice(0, 3)}${'*'.repeat(6)}${s.slice(-4)}`;
+}
+
+/** 取 token,缺失则抛出可操作的错误 */
+export function requireToken() {
+  const { token } = loadConfig();
+  if (!token) {
+    throw new Error(
+      '未找到 API 凭据。请任选其一:\n' +
+        '  1) 设置环境变量 FEME_API_TOKEN=<你的token>\n' +
+        '  2) 写入 /opt/data/fmode-identity.json 的 newapi_token 字段\n' +
+        '  3) 兼容旧变量 FMODE_API_KEY\n' +
+        '(本技能不会把 token 写入任何文件或日志)'
+    );
+  }
+  return token;
+}
+
+/**
+ * 生成到子进程的安全环境变量(供 ffmpeg 等外部命令使用),
+ * 不包含任何 token。
+ */
+export function safeEnv() {
+  const env = { ...process.env };
+  for (const k of [...TOKEN_ENV_KEYS, ...BASE_ENV_KEYS]) delete env[k];
+  return env;
+}
+
+/** 打印当前配置摘要(token 已脱敏) */
+export function describeConfig() {
+  const c = loadConfig();
+  return [
+    `baseUrl      : ${c.baseUrl}`,
+    `rootUrl      : ${c.rootUrl}`,
+    `token        : ${mask(c.token)}  (来源: ${c.tokenSource || '未配置'})`,
+    `identityFile : ${c.identityPath || '未找到'}`,
+  ].join('\n');
+}
+
+export { BASE_ENV_KEYS, TOKEN_ENV_KEYS, DEFAULT_BASE };

+ 87 - 0
lib/doctor.mjs

@@ -0,0 +1,87 @@
+// lib/doctor.mjs — 环境自检
+// 检查 node / ffmpeg / ffprobe / 凭据 / 各通道探活 / 可选依赖,给出可操作结论。
+
+import { VERSION, checkVersionConsistency, log } from './index.mjs';
+import { describeConfig, loadConfig } from './config.mjs';
+import { checkFfmpeg, checkDemucs } from './ffmpeg.mjs';
+import { probeAll } from './providers.mjs';
+
+export async function runDoctor(argv = []) {
+  if (argv.includes('--help') || argv.includes('-h')) {
+    console.log(`
+fmode-mv doctor — 环境自检
+
+检查项:Node 版本 / 三处版本一致性 / ffmpeg & ffprobe / 凭据 / 可选依赖(demucs,edge-tts) / 各通道探活
+
+用法:
+  fmode-mv doctor
+
+无需参数。全部通过返回 0,有阻塞项返回 1。
+`);
+    return 0;
+  }
+  let fail = 0;
+  let warn = 0;
+
+  console.log(`\n${'fmode-mv doctor'.padEnd(30, ' ')} v${VERSION}\n`);
+
+  // 1. Node
+  const major = Number(process.versions.node.split('.')[0]);
+  (major >= 18 ? log.ok : log.err)(`Node.js ${process.versions.node}${major >= 18 ? '' : ' —— 需要 >= 18(内置 fetch)'}`);
+  if (major < 18) fail++;
+
+  // 2. 版本一致性
+  const v = checkVersionConsistency();
+  (v.ok ? log.ok : log.err)(
+    `版本一致性:index=${v.version} package=${v.pkgVersion} SKILL.md=${v.skillVersion}` + (v.ok ? '' : ' ← 不一致')
+  );
+  if (!v.ok) fail++;
+
+  // 3. ffmpeg / ffprobe
+  const ff = checkFfmpeg();
+  for (const k of ['ffmpeg', 'ffprobe']) {
+    (ff[k].available ? log.ok : log.err)(`${k}: ${ff[k].version || '未找到'}`);
+    if (!ff[k].available) fail++;
+  }
+
+  // 4. 凭据
+  const cfg = loadConfig();
+  if (cfg.token) {
+    log.ok(`凭据已加载(${cfg.tokenSource}),令牌已脱敏:${'*'.repeat(6)}`);
+  } else {
+    log.err('未找到凭据:设置 FEME_API_TOKEN,或在 /opt/data/fmode-identity.json 写入 newapi_token');
+    fail++;
+  }
+  log.dim(`网关: ${cfg.baseUrl}`);
+  log.dim(`身份文件: ${cfg.identityPath || '未找到'}`);
+
+  // 5. 可选依赖
+  const dm = checkDemucs();
+  (dm.available ? log.ok : log.warn)(`demucs(高质量音轨分离,可选): ${dm.available ? dm.version : '未安装 —— 将用 ffmpeg 相位相减兜底'}`);
+  if (!dm.available) warn++;
+
+  // 6. 通道探活
+  console.log('');
+  log.step('通道探活(轻量,不做真实提交)');
+  if (cfg.token) {
+    const rows = await probeAll({ deep: false });
+    for (const r of rows) {
+      const mark = r.available === true ? '✓' : r.available === null ? '·' : '!';
+      const paint = r.available === true ? log.ok : r.available === null ? log.dim : log.warn;
+      paint(`${r.kind.padEnd(8)} ${r.detail}${r.model ? `  [${r.model}]` : ''}`);
+      if (r.available === false) warn++;
+    }
+  } else {
+    log.warn('跳过(无凭据)');
+  }
+
+  console.log('');
+  console.log('─'.repeat(56));
+  if (fail === 0) {
+    console.log(`✅ 环境就绪${warn ? `(${warn} 项非阻塞提示)` : ''}`);
+    console.log('   下一步:fmode-mv providers  查看能力现状;fmode-mv lyrics "主题"  试跑');
+    return 0;
+  }
+  console.log(`❌ ${fail} 项阻塞问题需要处理(见上)`);
+  return 1;
+}

+ 435 - 0
lib/ffmpeg.mjs

@@ -0,0 +1,435 @@
+// lib/ffmpeg.mjs — ffmpeg / ffprobe 封装
+//
+// 只用系统 ffmpeg(/usr/bin/ffmpeg)。零 npm 依赖。
+// 所有对外部命令的调用都走 run(),统一超时、统一错误信息(带 stderr 尾部)。
+
+import { spawn, spawnSync } from 'node:child_process';
+import { mkdirSync, existsSync, writeFileSync, readdirSync, statSync } from 'node:fs';
+import { dirname, join, resolve, basename } from 'node:path';
+import { safeEnv } from './config.mjs';
+
+export const FFMPEG = process.env.FFMPEG_BIN || 'ffmpeg';
+export const FFPROBE = process.env.FFPROBE_BIN || 'ffprobe';
+
+/** 检查 ffmpeg / ffprobe 是否可用 */
+export function checkFfmpeg() {
+  const out = {};
+  for (const [name, bin] of [
+    ['ffmpeg', FFMPEG],
+    ['ffprobe', FFPROBE],
+  ]) {
+    const r = spawnSync(bin, ['-version'], { encoding: 'utf8', timeout: 15_000 });
+    out[name] = r.status === 0
+      ? { available: true, version: (r.stdout || '').split('\n')[0].trim() }
+      : { available: false, version: null, error: (r.stderr || '').split('\n')[0] };
+  }
+  return out;
+}
+
+export function requireFfmpeg() {
+  const c = checkFfmpeg();
+  if (!c.ffmpeg.available || !c.ffprobe.available) {
+    throw new Error(
+      '缺少系统依赖 ffmpeg / ffprobe。\n' +
+        '  Ubuntu/Debian: sudo apt-get install -y ffmpeg\n' +
+        '  macOS: brew install ffmpeg\n' +
+        '(当前 ffmpeg: ' + (c.ffmpeg.version || '未找到') + ')'
+    );
+  }
+  return c;
+}
+
+/**
+ * 执行 ffmpeg(或任意命令)。
+ * @param {string[]} args
+ * @param {{bin?:string, timeout?:number, label?:string, cwd?:string}} opts
+ */
+export function run(args, opts = {}) {
+  const { bin = FFMPEG, timeout = 30 * 60_000, label = 'ffmpeg', cwd } = opts;
+  return new Promise((resolvePromise, reject) => {
+    const p = spawn(bin, args, { cwd, env: safeEnv() });
+    let stderr = '';
+    let stdout = '';
+    const timer = setTimeout(() => {
+      p.kill('SIGKILL');
+      reject(new Error(`${label} 超时(${Math.round(timeout / 1000)}s)`));
+    }, timeout);
+    p.stdout.on('data', (d) => (stdout += d));
+    p.stderr.on('data', (d) => {
+      stderr += d;
+      if (stderr.length > 200_000) stderr = stderr.slice(-100_000);
+    });
+    p.on('error', (e) => {
+      clearTimeout(timer);
+      reject(new Error(`${label} 启动失败:${e.message}(未安装 ${bin}?)`));
+    });
+    p.on('close', (code) => {
+      clearTimeout(timer);
+      if (code === 0) return resolvePromise({ stdout, stderr });
+      const tail = stderr.split('\n').filter(Boolean).slice(-8).join('\n  ');
+      reject(new Error(`${label} 退出码 ${code}:\n  ${tail}`));
+    });
+  });
+}
+
+/** ffprobe 读取媒体信息 */
+export async function probe(file) {
+  const { stdout } = await run(
+    ['-v', 'error', '-print_format', 'json', '-show_format', '-show_streams', file],
+    { bin: FFPROBE, label: 'ffprobe', timeout: 60_000 }
+  );
+  const j = JSON.parse(stdout);
+  const v = (j.streams || []).find((s) => s.codec_type === 'video');
+  const a = (j.streams || []).find((s) => s.codec_type === 'audio');
+  const num = (x) => (x == null || x === 'N/A' ? null : Number(x));
+  return {
+    raw: j,
+    duration: num(j.format?.duration) ?? num(v?.duration) ?? 0,
+    size: num(j.format?.size) ?? (existsSync(file) ? statSync(file).size : 0),
+    hasVideo: !!v,
+    hasAudio: !!a,
+    width: num(v?.width),
+    height: num(v?.height),
+    fps: parseFps(v?.r_frame_rate),
+    vcodec: v?.codec_name || null,
+    acodec: a?.codec_name || null,
+    sampleRate: num(a?.sample_rate),
+    channels: num(a?.channels),
+  };
+}
+
+function parseFps(r) {
+  if (!r || r === 'N/A') return null;
+  const [a, b] = String(r).split('/').map(Number);
+  if (!b) return a || null;
+  return +(a / b).toFixed(3);
+}
+
+export function ensureDir(dir) {
+  mkdirSync(dir, { recursive: true });
+  return dir;
+}
+
+export function fileSize(p) {
+  try {
+    return statSync(p).size;
+  } catch {
+    return 0;
+  }
+}
+
+export function humanSize(bytes) {
+  if (!bytes) return '0B';
+  const u = ['B', 'KB', 'MB', 'GB'];
+  let i = 0;
+  let n = bytes;
+  while (n >= 1024 && i < u.length - 1) {
+    n /= 1024;
+    i++;
+  }
+  return `${n.toFixed(n < 10 && i > 0 ? 1 : 0)}${u[i]}`;
+}
+
+// ==================== 音视频常用操作 ====================
+
+/** 从视频中提取音频 */
+export async function extractAudio(input, output, { format = 'wav', sampleRate = 44100, channels = 2 } = {}) {
+  ensureDir(dirname(output));
+  await run([
+    '-y', '-i', input,
+    '-vn',
+    '-acodec', format === 'wav' ? 'pcm_s16le' : format === 'mp3' ? 'libmp3lame' : 'aac',
+    '-ar', String(sampleRate), '-ac', String(channels),
+    output,
+  ], { label: 'extractAudio' });
+  return output;
+}
+
+/** 把音频合到视频上(替换音轨),支持淡出与时长裁剪 */
+export async function muxAudio(video, audio, output, { duration, fadeOut = 0, audioBitrate = '192k', volume = 1 } = {}) {
+  ensureDir(dirname(output));
+  const filters = [];
+  if (volume !== 1) filters.push(`volume=${volume}`);
+  if (fadeOut > 0 && duration) {
+    filters.push(`afade=t=out:st=${Math.max(0, duration - fadeOut).toFixed(3)}:d=${fadeOut}`);
+  }
+  const af = filters.length ? ['-af', filters.join(',')] : [];
+  const args = [
+    '-y', '-i', video, '-i', audio,
+    '-map', '0:v:0', '-map', '1:a:0',
+    '-c:v', 'copy',
+    '-c:a', 'aac', '-b:a', audioBitrate,
+    ...af,
+    ...(duration ? ['-t', String(duration)] : []),
+    '-shortest',
+    output,
+  ];
+  await run(args, { label: 'muxAudio' });
+  return output;
+}
+
+/** 拼接多段视频(统一重编码,避免参数不一致导致 concat 失败) */
+export async function concat(clips, output, { width = 1280, height = 720, fps = 30, transition = 0, withAudio = false } = {}) {
+  ensureDir(dirname(output));
+  if (!clips.length) throw new Error('concat 需要至少一个片段');
+  if (clips.length === 1) {
+    await run(['-y', '-i', clips[0], '-c', 'copy', output], { label: 'concat(single)' });
+    return output;
+  }
+
+  const args = ['-y'];
+  for (const c of clips) args.push('-i', c);
+
+  const scale = `scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1,fps=${fps}`;
+
+  if (transition > 0) {
+    // xfade 交叉溶解
+    let filter = '';
+    let prev = '[0:v]';
+    for (let i = 1; i < clips.length; i++) {
+      const off = i * (transition > 0 ? 0.5 : 0);
+      const out = i === clips.length - 1 ? '[vout]' : `[v${i}]`;
+      filter += `${prev}[${i}:v]xfade=transition=fade:duration=${transition}:offset=${off}${out};`;
+      prev = out;
+    }
+    const mapArgs = ['-map', '[vout]', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-crf', '18'];
+    await run([...args, '-filter_complex', filter.replace(/;$/, ''), ...mapArgs, output], { label: 'concat(xfade)' });
+    return output;
+  }
+
+  const fc = clips.map((_, i) => `[${i}:v]${scale}[v${i}]`).join(';') + ';' + clips.map((_, i) => `[v${i}]`).join('') + `concat=n=${clips.length}:v=1:a=0[vout]`;
+  await run(
+    [...args, '-filter_complex', fc, '-map', '[vout]', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-crf', '18', output],
+    { label: 'concat' }
+  );
+  return output;
+}
+
+/** 给视频烧硬字幕(SRT / ASS) */
+export async function burnSubtitles(video, subtitleFile, output, { fontSize = 20, marginV = 28, fontName = 'Noto Sans CJK SC' } = {}) {
+  ensureDir(dirname(output));
+  const isAss = subtitleFile.toLowerCase().endsWith('.ass');
+  const style = `FontName=${fontName},FontSize=${fontSize},PrimaryColour=&H00FFFFFF,OutlineColour=&H00000000,BorderStyle=1,Outline=2,Shadow=0,Alignment=2,MarginV=${marginV}`;
+  const esc = subtitleFile.replace(/\\/g, '/').replace(/:/g, '\\:').replace(/'/g, "\\'");
+  const vf = isAss ? `ass='${esc}'` : `subtitles='${esc}':force_style='${style}'`;
+  await run(['-y', '-i', video, '-vf', vf, '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-crf', '18', '-c:a', 'copy', output], {
+    label: 'burnSubtitles',
+  });
+  return output;
+}
+
+/** 静帧 + Ken Burns 运镜(视频通道未开通时的兜底动态镜头) */
+export async function kenBurns(image, output, { duration = 5, width = 1280, height = 720, fps = 30, zoom = 0.12, direction = 'in' } = {}) {
+  ensureDir(dirname(output));
+  const frames = Math.max(1, Math.round(duration * fps));
+  const zEnd = direction === 'out' ? 1 + zoom : 1 + zoom;
+  const zStart = direction === 'out' ? 1 + zoom : 1;
+  // zoompan 在 4K 画布上运算以获得平滑慢推
+  const pre = `scale=${width * 2}:${height * 2}:force_original_aspect_ratio=increase,crop=${width * 2}:${height * 2}`;
+  const zp =
+    `zoompan=z='${zStart}+(${zEnd}-${zStart})*on/${frames}':` +
+    `x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':d=${frames}:s=${width}x${height}:fps=${fps}`;
+  await run(
+    ['-y', '-loop', '1', '-i', image, '-t', String(duration), '-vf', `${pre},${zp},format=yuv420p`, '-r', String(fps), '-c:v', 'libx264', '-crf', '19', output],
+    { label: 'kenBurns' }
+  );
+  return output;
+}
+
+/** 纯色背景 + 音频(无画面素材时的占位成片) */
+export async function colorWithAudio(audio, output, { duration, width = 1280, height = 720, color = 'black', fps = 30 } = {}) {
+  ensureDir(dirname(output));
+  await run(
+    [
+      '-y', '-f', 'lavfi', '-i', `color=c=${color}:s=${width}x${height}:r=${fps}`,
+      '-i', audio,
+      '-map', '0:v', '-map', '1:a',
+      ...(duration ? ['-t', String(duration)] : []),
+      '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-shortest', output,
+    ],
+    { label: 'colorWithAudio' }
+  );
+  return output;
+}
+
+/** 生成静音音轨 */
+export async function silence(output, duration, { sampleRate = 44100, channels = 2 } = {}) {
+  ensureDir(dirname(output));
+  await run(
+    ['-y', '-f', 'lavfi', '-i', `anullsrc=r=${sampleRate}:cl=${channels === 1 ? 'mono' : 'stereo'}`, '-t', String(duration), '-c:a', 'pcm_s16le', output],
+    { label: 'silence' }
+  );
+  return output;
+}
+
+/** 响度归一化(成片听感统一) */
+export async function normalizeLoudness(input, output, { target = -14 } = {}) {
+  ensureDir(dirname(output));
+  const pass1 = await run(['-y', '-i', input, '-af', `loudnorm=I=${target}:print_format=json`, '-f', 'null', '-'], {
+    label: 'loudnorm(pass1)',
+  });
+  let measured = {};
+  try {
+    const m = pass1.stderr.match(/\{[\s\S]*?"target_offset"[\s\S]*?\}/);
+    if (m) measured = JSON.parse(m[0]);
+  } catch {
+    /* 忽略解析失败,退回单遍 */
+  }
+  const af = measured.input_i
+    ? `loudnorm=I=${target}:TP=-1.5:LRA=11:measured_I=${measured.input_i}:measured_TP=${measured.input_tp}:measured_LRA=${measured.input_lra}:measured_thresh=${measured.input_thresh}:offset=${measured.target_offset}:linear=true`
+    : `loudnorm=I=${target}:TP=-1.5:LRA=11`;
+  await run(['-y', '-i', input, '-af', af, '-c:a', 'aac', '-b:a', '192k', output], { label: 'loudnorm(pass2)' });
+  return output;
+}
+
+/** 生成 MP3 伴奏/人声的简易分离(纯 ffmpeg 兜底,见 scripts/separate.mjs 说明) */
+export async function separateByChannelPhase(input, { outDir, mode = 'instrumental' } = {}) {
+  ensureDir(outDir);
+  const out = join(outDir, `${mode}.wav`);
+  // 中置声道消除:人声通常在中央,L-R 相减可压低人声 → 近似伴奏
+  // 中置提取: (L+R)/2 - (L-R)/2 的近似用 pan 实现
+  const af =
+    mode === 'instrumental'
+      ? 'pan=stereo|c0=c0|c1=-c1,volume=1.4' // 侧信号(近似伴奏)
+      : 'pan=mono|c0=0.5*c0+0.5*c1'; // 中置(近似人声/主唱)
+  await run(['-y', '-i', input, '-af', af, '-ar', '44100', out], { label: 'separate' });
+  return out;
+}
+
+/** 用 demucs(若安装)做高质量分离 */
+export function checkDemucs() {
+  for (const cmd of ['demucs', 'python3']) {
+    const r = spawnSync(cmd, cmd === 'python3' ? ['-c', 'import demucs;print(demucs.__version__)'] : ['--help'], {
+      encoding: 'utf8',
+      timeout: 30_000,
+    });
+    if (r.status === 0) {
+      if (cmd === 'demucs') return { available: true, cmd: 'demucs', version: (r.stdout || '').split('\n')[0] };
+      return { available: true, cmd: 'python3 -m demucs', version: (r.stdout || '').trim() };
+    }
+  }
+  return { available: false };
+}
+
+/** 用 demucs 分离(可选增强路径) */
+export async function separateWithDemucs(input, outDir, { model = 'htdemucs' } = {}) {
+  ensureDir(outDir);
+  const d = checkDemucs();
+  if (!d.available) throw new Error('未检测到 demucs(可选依赖,pip install demucs)');
+  const args = d.cmd === 'demucs' ? ['-n', model, '-o', outDir, input] : ['-m', 'demucs', '-n', model, '-o', outDir, input];
+  const [bin, ...rest] = d.cmd.split(' ');
+  await run([...rest, ...args], { bin, label: 'demucs', timeout: 30 * 60_000 });
+  const base = basename(input).replace(/\.[^.]+$/, '');
+  return {
+    vocals: join(outDir, model, `${base}.vocals.wav`),
+    instrumental: join(outDir, model, `${base}.no_vocals.wav`),
+    drums: join(outDir, model, `${base}.drums.wav`),
+    bass: join(outDir, model, `${base}.bass.wav`),
+    other: join(outDir, model, `${base}.other.wav`),
+  };
+}
+
+// ==================== 卡点:能量包络节拍检测(纯 Node,零依赖)====================
+
+/**
+ * 解码音频为 PCM 并做能量包络节拍检测,返回节拍时间点(秒)。
+ * 不依赖 aubio 等外部库,够用且稳定。
+ * @returns {Promise<{bpm:number|null, beats:number[], duration:number}>}
+ */
+export async function detectBeats(audioFile, { sampleRate = 11025, hop = 1024, sensitivity = 1.4 } = {}) {
+  const tmp = join('/tmp', `fmode-mv-beat-${process.pid}-${Date.now()}.raw`);
+  await run(
+    ['-y', '-i', audioFile, '-ac', '1', '-ar', String(sampleRate), '-f', 's16le', '-acodec', 'pcm_s16le', tmp],
+    { label: 'decode(for beat detection)' }
+  );
+  const { readFileSync, unlinkSync } = await import('node:fs');
+  const buf = readFileSync(tmp);
+  try { unlinkSync(tmp); } catch { /* ignore */ }
+
+  const samples = new Int16Array(buf.buffer, buf.byteOffset, Math.floor(buf.length / 2));
+  const frames = Math.floor(samples.length / hop);
+  const energy = new Float64Array(frames);
+  for (let f = 0; f < frames; f++) {
+    let sum = 0;
+    const off = f * hop;
+    for (let i = 0; i < hop; i++) {
+      const v = samples[off + i] / 32768;
+      sum += v * v;
+    }
+    energy[f] = Math.sqrt(sum / hop);
+  }
+
+  // 局部均值 + 阈值攻陷 → onset 峰值
+  const win = 16;
+  const beats = [];
+  for (let f = 1; f < frames - 1; f++) {
+    let sum = 0;
+    let n = 0;
+    for (let k = Math.max(0, f - win); k <= Math.min(frames - 1, f + win); k++) {
+      sum += energy[k];
+      n++;
+    }
+    const local = sum / n;
+    const th = local * sensitivity;
+    if (energy[f] > th && energy[f] >= energy[f - 1] && energy[f] > energy[f + 1]) {
+      const t = (f * hop) / sampleRate;
+      if (!beats.length || t - beats[beats.length - 1] > 0.18) beats.push(+t.toFixed(3));
+    }
+  }
+
+  // 由中位间隔估计 BPM
+  let bpm = null;
+  if (beats.length > 4) {
+    const gaps = [];
+    for (let i = 1; i < beats.length; i++) gaps.push(beats[i] - beats[i - 1]);
+    gaps.sort((a, b) => a - b);
+    const med = gaps[Math.floor(gaps.length / 2)];
+    if (med > 0.2) {
+      let b = 60 / med;
+      while (b < 70) b *= 2;
+      while (b > 180) b /= 2;
+      bpm = +b.toFixed(1);
+    }
+  }
+  const duration = samples.length / sampleRate;
+  return { bpm, beats, duration: +duration.toFixed(3) };
+}
+
+/**
+ * 把分镜按节拍对齐:给定每镜时长,吸附到最近的节拍点。
+ * @param {{beats:number[], duration:number}} beatInfo
+ * @param {Array<{duration:number}>} shots
+ */
+export function alignToBeats(beatInfo, shots, { offset = 0 } = {}) {
+  if (!beatInfo?.beats?.length) return shots;
+  const out = [];
+  let t = offset;
+  for (const s of shots) {
+    let target = t + s.duration;
+    // 找最近的节拍点
+    let best = target;
+    let bestD = Infinity;
+    for (const b of beatInfo.beats) {
+      const d = Math.abs(b - target);
+      if (d < bestD) {
+        bestD = d;
+        best = b;
+      }
+    }
+    const snapped = bestD <= 0.6 ? best : target;
+    const dur = Math.max(0.8, +(snapped - t).toFixed(3));
+    out.push({ ...s, start: +t.toFixed(3), duration: dur });
+    t = +(t + dur).toFixed(3);
+  }
+  return out;
+}
+
+/** 列出目录下的媒体文件 */
+export function listMedia(dir, exts = ['.mp4', '.mov', '.mkv', '.webm']) {
+  if (!existsSync(dir)) return [];
+  return readdirSync(dir)
+    .filter((f) => exts.includes(f.slice(f.lastIndexOf('.')).toLowerCase()))
+    .sort()
+    .map((f) => join(dir, f));
+}
+

+ 169 - 0
lib/index.mjs

@@ -0,0 +1,169 @@
+// lib/index.mjs — fmode-mv 主入口
+//
+// 版本号三处必须一致:package.json / SKILL.md frontmatter / 本文件 VERSION。
+// 用 `fmode-mv version --check` 可自动校验。
+
+export const VERSION = '0.1.0';
+export const NAME = 'fmode-mv';
+export const SKILL = 'skill-mv';
+
+import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
+import { dirname, join, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+export const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
+
+// 重新导出各模块,脚本统一从 index.mjs 取用
+export * from './config.mjs';
+export * from './api.mjs';
+export * from './providers.mjs';
+export * from './ffmpeg.mjs';
+
+// ==================== 工作区约定 ====================
+//
+// work/                      —— 本次生产的中间产物(不入库)
+//   song.json                作曲填词结果
+//   storyboard.json          分镜脚本
+//   audio/                   音乐、人声、伴奏、配音
+//   frames/                  关键帧图片
+//   clips/                   生成的视频片段
+//   lipsync/                 唇形驱动结果
+//   subtitles.srt            字幕
+// out/                       —— 最终成片
+
+export const WORK = process.env.FMODE_MV_WORK ? resolve(process.env.FMODE_MV_WORK) : join(ROOT, 'work');
+export const OUT = process.env.FMODE_MV_OUT ? resolve(process.env.FMODE_MV_OUT) : join(ROOT, 'out');
+
+export const paths = {
+  work: WORK,
+  out: OUT,
+  audio: join(WORK, 'audio'),
+  frames: join(WORK, 'frames'),
+  clips: join(WORK, 'clips'),
+  lipsync: join(WORK, 'lipsync'),
+  song: join(WORK, 'song.json'),
+  storyboard: join(WORK, 'storyboard.json'),
+  script: join(WORK, 'script.json'),
+  subtitles: join(WORK, 'subtitles.srt'),
+};
+
+export function ensureWork() {
+  for (const d of [paths.work, paths.out, paths.audio, paths.frames, paths.clips, paths.lipsync]) mkdirSync(d, { recursive: true });
+  return paths.work;
+}
+
+// ==================== JSON 读写 ====================
+
+export function writeJSON(file, data) {
+  mkdirSync(dirname(file), { recursive: true });
+  writeFileSync(file, JSON.stringify(data, null, 2) + '\n', 'utf8');
+  return file;
+}
+
+export function readJSON(file, fallback = null) {
+  try {
+    if (!existsSync(file)) return fallback;
+    return JSON.parse(readFileSync(file, 'utf8'));
+  } catch (e) {
+    throw new Error(`读取 JSON 失败 ${file}: ${e.message}`);
+  }
+}
+
+// ==================== 日志 ====================
+
+const C = {
+  reset: '\x1b[0m',
+  dim: '\x1b[2m',
+  red: '\x1b[31m',
+  green: '\x1b[32m',
+  yellow: '\x1b[33m',
+  blue: '\x1b[34m',
+  cyan: '\x1b[36m',
+  bold: '\x1b[1m',
+};
+const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
+const paint = (c, s) => (useColor ? `${c}${s}${C.reset}` : String(s));
+
+export const log = {
+  step: (s) => console.log(`\n${paint(C.bold + C.blue, '▶ ' + s)}`),
+  ok: (s) => console.log(`  ${paint(C.green, '✓')} ${s}`),
+  warn: (s) => console.log(`  ${paint(C.yellow, '!')} ${s}`),
+  err: (s) => console.error(`  ${paint(C.red, '✗')} ${s}`),
+  info: (s) => console.log(`  ${s}`),
+  dim: (s) => console.log(paint(C.dim, `  ${s}`)),
+  raw: (s) => console.log(s),
+  key: (k, v) => console.log(`  ${paint(C.cyan, k.padEnd(14))} ${v}`),
+};
+
+/** 打印未开通通道的标准提示(统一格式,保证「不静默失败」) */
+export function reportUnavailable(kind, err) {
+  log.err(`通道「${kind}」不可用:${String(err.message).split('\n')[0]}`);
+  if (err.hint) {
+    console.log('');
+    console.log(paint(C.yellow, '  可操作方案:'));
+    for (const line of String(err.hint).split('\n')) console.log(`  ${line}`);
+  }
+  console.log('');
+  console.log(paint(C.dim, `  用 \`fmode-mv providers\` 查看全部通道现状。`));
+}
+
+// ==================== 参数解析 ====================
+
+/**
+ * 极简参数解析:支持 --k v / --k=v / --flag / 位置参数。
+ * @returns {{_:string[], flags:object}}
+ */
+export function parseArgs(argv = process.argv.slice(2)) {
+  const _ = [];
+  const flags = {};
+  for (let i = 0; i < argv.length; i++) {
+    const a = argv[i];
+    if (a.startsWith('--')) {
+      const eq = a.indexOf('=');
+      if (eq > -1) {
+        flags[a.slice(2, eq)] = a.slice(eq + 1);
+      } else {
+        const key = a.slice(2);
+        const next = argv[i + 1];
+        if (next === undefined || next.startsWith('--')) flags[key] = true;
+        else {
+          flags[key] = next;
+          i++;
+        }
+      }
+    } else if (a.startsWith('-') && a.length > 1) {
+      flags[a.slice(1)] = true;
+    } else {
+      _.push(a);
+    }
+  }
+  return { _, flags };
+}
+
+/** 三处版本一致性校验 */
+export function checkVersionConsistency() {
+  const issues = [];
+  const pkgPath = join(ROOT, 'package.json');
+  const skillPath = join(ROOT, 'SKILL.md');
+  let pkgVer = null;
+  let skillVer = null;
+  try {
+    pkgVer = JSON.parse(readFileSync(pkgPath, 'utf8')).version;
+  } catch (e) {
+    issues.push(`package.json 读取失败:${e.message}`);
+  }
+  try {
+    const txt = readFileSync(skillPath, 'utf8');
+    const m = txt.match(/^---([\s\S]*?)---/);
+    const fm = m ? m[1] : '';
+    skillVer = (fm.match(/^version:\s*"?([^"\n]+)"?/m) || [])[1]?.trim() || null;
+  } catch (e) {
+    issues.push(`SKILL.md 读取失败:${e.message}`);
+  }
+  const ok = pkgVer === VERSION && skillVer === VERSION;
+  if (pkgVer !== VERSION) issues.push(`package.json=${pkgVer} ≠ index.mjs=${VERSION}`);
+  if (skillVer !== VERSION) issues.push(`SKILL.md=${skillVer} ≠ index.mjs=${VERSION}`);
+  return { ok, version: VERSION, pkgVersion: pkgVer, skillVersion: skillVer, issues };
+}
+
+export default { VERSION, NAME, SKILL, paths, checkVersionConsistency };

+ 142 - 0
lib/prompts.mjs

@@ -0,0 +1,142 @@
+// lib/prompts.mjs — 提示词模板库(代码侧)
+//
+// 人读版见 references/prompt-templates.md;本文件是脚本直接调用的构建器。
+// 原则:人物一致性靠「固定外观锚点(appearance anchor)」逐镜头复述,
+//       空镜靠「场景锚点 + 运镜词」,避免 AI 每镜换脸/换景。
+
+/** 作曲填词的用户提示词 */
+export function buildLyricsPrompt({ theme, genre = '', mood = '', style = '', language = '中文', extra = '' }) {
+  return `请为下面这个主题创作一首${language}流行歌曲的完整素材。
+
+【主题】${theme}
+${genre ? `【指定曲风】${genre}` : '【曲风】你来定,但需说明理由'}
+${mood ? `【情绪基调】${mood}` : ''}
+${style ? `【补充要求】${style}` : ''}
+${extra ? `【其它】${extra}` : ''}
+
+要求:
+- 歌词长度适合 2.5~3.5 分钟的歌曲,段落完整(主歌2段、副歌2-3次、可含桥段)。
+- 副歌必须有记忆点,句尾尽量押韵。
+- 歌词必须原创,不得使用任何已有歌曲的句子。
+- 给出英文 stylePrompt,供 AI 音乐模型直接使用(含曲风、乐器、速度、人声类型、混音质感)。`;
+}
+
+/** 分镜脚本的用户提示词(产线A:歌曲 MV) */
+export function buildStoryboardPrompt({ song, shotCount = 8, aspect = '16:9', perShot = 6, project = '' }) {
+  const structure = (song.structure || []).join(' → ');
+  return `为下面这首歌设计一支 AI 生成的音乐 MV 分镜(全程无实拍,纯 AI 生成画面)。
+
+【歌名】${song.title}
+【曲风/情绪】${song.genre || '-'} / ${song.mood || '-'} / ${song.bpm || '?'} BPM
+【结构】${structure}
+【画面比例】${aspect}
+【镜头数量】${shotCount} 个(必须控制在 ${shotCount}±2 个,这是硬性约束)
+【单镜时长】每镜 ${perShot} 秒左右,**必须在 ${Math.max(3, perShot - 2)}~${Math.min(8, perShot + 2)} 秒之间**。
+  注意:AI 图生视频单段现实上限约 8 秒,**不要**生成 20 秒、30 秒的镜头。
+【总时长】因此全片约 ${shotCount * perShot} 秒;不要为了凑原曲长度而增加镜头数量或拉长单镜。
+  如需覆盖整首歌,请由使用者调大 --shots,而不是让单个镜头变长。
+${project ? `【项目背景】${project}` : ''}
+
+【歌词】
+${song.lyrics}
+
+设计要求:
+1. 先定义「人物设定 appearanceAnchor」:一句话锁定主角外貌(性别/年龄/发型/服装/气质/镜头感),
+   之后每个含人物的镜头 description 与 imagePrompt 都必须原样复述这段锚点,保证跨镜头人物一致。
+2. 镜头分为两类:
+   - "broll":空镜/场景镜头(城市、天空、房间、物件特写…),无人物或人物非主体。
+   - "perf":人物演唱镜头(正面/侧脸/半身,情绪表演)。
+   两类交替出现,情绪曲线从铺垫 → 蓄积 → 爆发 → 收束,与歌词段落对应。
+3. 每个镜头的 imagePrompt 必须是「可独立投喂文生图模型」的完整英文提示词:
+   主体 + 动作/状态 + 环境 + 光线 + 镜头语言(焦段/景别/角度)+ 风格 + 画质词,
+   并在含人物时包含 appearanceAnchor 的英文版本。
+4. 每个镜头的 videoPrompt 是「图生视频」的运镜描述(英文):
+   主体动作 + 镜头运动(push in / pull back / pan left / orbit / handheld)+ 节奏感,
+   不含画面内容描述(内容来自关键帧),6 秒内能完成。
+5. 若某镜头需要唇形驱动(人物正脸演唱),把 "lipSync": true,其余为 false。
+   唇形驱动镜头总数建议 ≤ 4 个。
+6. 所有镜头时长之和应接近歌曲总时长。`;
+}
+
+/** 口播稿的用户提示词(产线B) */
+export function buildScriptPrompt({ topic, durationSec = 90, audience = '', tone = '专业但口语化', points = [], extra = '' }) {
+  const words = Math.round(durationSec * 3.6); // 中文口播约 3.5~4 字/秒
+  return `写一篇可直接用于 AI 语音合成(TTS)的口播讲解稿。
+
+【主题】${topic}
+【目标时长】约 ${durationSec} 秒(约 ${words} 字,中文口播语速约 3.6 字/秒)
+${audience ? `【受众】${audience}` : ''}
+【语气】${tone}
+${points.length ? `【必须覆盖的要点】\n- ${points.join('\n- ')}` : ''}
+${extra ? `【其它】${extra}` : ''}
+
+写作要求:
+1. 口语化、短句为主,避免书面语与长难句(TTS 读长句会断气)。
+2. 开头 3 秒必须有钩子(提问/反常识/利益点),结尾一句行动号召。
+3. 数字与专有名词要写出来(会直接读出来),必要时用中文读法(如 "百分之三十")。`;
+}
+
+/** 产线B 讲解画面分镜的用户提示词 */
+export function buildTalkStoryboardPrompt({ script, durationSec, aspect = '16:9', shotCount = 8, project = '', hostAnchor = '' }) {
+  return `为下面这篇口播稿设计 AI 生成的讲解视频分镜(纯 AI 生成画面,无实拍)。
+
+【画面比例】${aspect}
+【总时长】约 ${durationSec} 秒
+【镜头数量】${shotCount} 个(必须控制在 ${shotCount}±2 个,这是硬性约束)
+【单镜时长】每镜约 ${Math.round(durationSec / shotCount)} 秒,且**必须落在 4~10 秒之间**(AI 图生视频单段上限约 10 秒)
+${project ? `【项目背景】${project}` : ''}
+${hostAnchor ? `【讲者设定】${hostAnchor}(每个含讲者的镜头都必须原样复述该外观锚点)` : '【讲者设定】请先定义一位讲者的外观锚点 appearanceAnchor'}
+
+【口播稿全文】
+${script}
+
+设计要求:
+1. 每句/每小段口播都要有对应画面,画面与语义强相关(讲数据就给图表感画面,讲场景就给场景画面)。
+2. 镜头两类交替:
+   - "talking":讲者正面半身镜头(可选唇形驱动 lipSync:true,总数 ≤ 3 个)。
+   - "broll":相关场景/物件/概念图(大量使用,占多数,降低口型不一致的观感风险)。
+3. 每个镜头给出:start(秒)、duration(秒)、section(对应口播文本片段)、
+   narration(该镜头对应的口播文字,用于生成字幕)、
+   description(中文画面描述)、imagePrompt(英文,完整可投喂)、videoPrompt(英文运镜)。
+4. 每个镜头的中文字幕文本放在 "subtitle" 字段(≤ 20 字,便于单行显示)。`;
+}
+
+/** 关键帧:把分镜镜头转成文生图 prompt(含人物一致性锚点) */
+export function buildKeyframePrompt(shot, { appearanceAnchor = '', style = '', aspect = '16:9' } = {}) {
+  const parts = [];
+  if (shot.imagePrompt) parts.push(shot.imagePrompt);
+  else parts.push(shot.description || shot.prompt || '');
+  if (shot.hasCharacter !== false && appearanceAnchor && !String(shot.imagePrompt || '').includes(appearanceAnchor.slice(0, 20))) {
+    parts.push(`Character appearance (keep identical across shots): ${appearanceAnchor}`);
+  }
+  if (style) parts.push(style);
+  parts.push('cinematic still frame, high detail, film grain, color graded, 4k');
+  return parts.filter(Boolean).join(', ');
+}
+
+/** 视频生成:把分镜镜头转成图生视频 prompt */
+export function buildVideoPrompt(shot) {
+  return shot.videoPrompt || 'subtle natural motion, slow push in, cinematic';
+}
+
+/** 画面比例 → 文生图尺寸 */
+export function aspectToSize(aspect = '16:9', tier = 'standard') {
+  const map = {
+    '16:9': { standard: '1536x864', large: '1792x1024' },
+    '9:16': { standard: '864x1536', large: '1024x1792' },
+    '1:1': { standard: '1024x1024', large: '1024x1024' },
+    '4:3': { standard: '1152x864', large: '1365x1024' },
+  };
+  return (map[aspect] || map['16:9'])[tier] || '1024x1024';
+}
+
+/** 视频生成:比例 → 视频尺寸 */
+export function aspectToVideoSize(aspect = '16:9') {
+  return aspect === '9:16' ? '720x1280' : aspect === '1:1' ? '960x960' : '1280x720';
+}
+
+/** 默认人物外观锚点模板(用户不给时使用) */
+export const DEFAULT_ANCHOR = {
+  song: 'Asian woman in her late 20s, shoulder-length black hair with soft waves, natural makeup, wearing a deep navy oversized jacket over a white tee, calm melancholic gaze, cinematic presence',
+  talk: 'Asian man in his 30s, short neat black hair, light stubble, wearing a charcoal knit sweater, warm approachable expression, professional presenter presence',
+};

+ 437 - 0
lib/providers.mjs

@@ -0,0 +1,437 @@
+// lib/providers.mjs — 可插拔 provider 注册表
+//
+// 设计原则(务必遵守):
+//   1. 每个 provider 显式声明 available —— 通道没开通就是 false,绝不伪装成能用。
+//   2. probe() 会真的去探活(默认轻量:查 /models 列表;deep 模式才真提交任务)。
+//   3. 调用未开通通道时抛 ProviderUnavailableError,附「怎么换通道 / 怎么对接」的指引。
+//   4. 用户可以用环境变量或 providers.json 覆盖任何 provider 的模型名与端点,
+//      不需要改代码 —— 这是「可插拔」的落点。
+//
+// 能力现状以本文件为唯一事实来源;references/capability-matrix.md 与之保持同步。
+
+import { loadConfig, requireToken } from './config.mjs';
+import { request, ProviderUnavailableError, GatewayError, runAsyncTask, chat } from './api.mjs';
+
+const cfg = () => loadConfig();
+
+/** 读取 provider 覆盖配置(环境变量 / 可选 providers.json) */
+function overrides(kind) {
+  const c = loadConfig();
+  const o = {};
+
+  // 环境变量覆盖:FEME_<KIND>_MODEL / FEME_<KIND>_ENDPOINT
+  const K = kind.toUpperCase().replace(/[^A-Z0-9]/g, '_');
+  if (process.env[`FEME_${K}_MODEL`]) o.model = process.env[`FEME_${K}_MODEL`];
+  if (process.env[`FEME_${K}_ENDPOINT`]) o.endpoint = process.env[`FEME_${K}_ENDPOINT`];
+
+  // 身份文件里的 providers 段
+  const p = c.identity?.providers?.[kind];
+  if (p && typeof p === 'object') Object.assign(o, p);
+
+  return o;
+}
+
+/** 通用 /models 探活:模型是否在网关模型列表里 */
+async function probeModelList(model) {
+  try {
+    const res = await request('/models', { timeout: 20_000, retries: 0 });
+    const ids = (res.body?.data || []).map((m) => m.id);
+    const hit = ids.find((x) => x === model) || ids.find((x) => x.includes(model)) || null;
+    return {
+      available: !!hit,
+      detail: hit ? `模型 ${hit} 在网关模型列表中` : `模型 ${model} 不在网关模型列表中(当前共 ${ids.length} 个模型)`,
+      models: ids,
+    };
+  } catch (e) {
+    return { available: false, detail: `探活失败:${e.message.split('\n')[0]}` };
+  }
+}
+
+
+/**
+ * 判断一个端点是否「真的存在」。
+ * 关键陷阱:网关根域对未知路径会返回 SPA 的 index.html(HTTP 200 + text/html),
+ * 不能只看状态码,必须看 Content-Type 和响应体形状。
+ */
+async function probeEndpoint(pathOrUrl, { method = 'POST', body, timeout = 20_000, base } = {}) {
+  const b = base || cfg().rootUrl;
+  const url = /^https?:\/\//.test(pathOrUrl) ? pathOrUrl : `${b}${pathOrUrl}`;
+  let res;
+  try {
+    res = await fetch(url, {
+      method,
+      headers: {
+        Authorization: `Bearer ${requireToken()}`,
+        'Content-Type': 'application/json',
+        Accept: 'application/json',
+      },
+      body: body ? JSON.stringify(body) : undefined,
+      signal: AbortSignal.timeout(timeout),
+    });
+  } catch (e) {
+    return { exists: false, status: 0, detail: `无法连接(${e.message})` };
+  }
+
+  const ct = (res.headers.get('content-type') || '').toLowerCase();
+  const text = (await res.text().catch(() => '')).slice(0, 400);
+
+  // SPA 兜底页 → 端点不存在
+  if (ct.includes('text/html') || /^\s*<!doctype html/i.test(text) || /^\s*<html/i.test(text)) {
+    return { exists: false, status: res.status, spa: true, detail: `端点不存在(网关返回前端页面兜底,HTTP ${res.status})` };
+  }
+
+  let json = null;
+  try { json = JSON.parse(text); } catch { /* 非 JSON */ }
+
+  // 路由存在但方法不对(405)→ 端点存在
+  if (res.status === 405) return { exists: true, status: 405, json, detail: '端点存在(405 方法不允许)' };
+
+  // 明确的上游/渠道错误 → 端点存在但不可用
+  const msg = json?.error?.message || json?.message || text;
+  const lower = String(msg).toLowerCase();
+  const noChannel =
+    lower.includes('no available channel') || lower.includes('model_not_found') || lower.includes('task request failed');
+
+  if (noChannel) return { exists: true, status: res.status, json, noChannel: true, detail: `端点存在但渠道未开通(HTTP ${res.status}):${String(msg).slice(0, 120)}` };
+
+  if (res.status === 404) return { exists: false, status: 404, json, detail: `端点不存在(404)` };
+
+  return { exists: true, status: res.status, json, detail: `端点存在(HTTP ${res.status})` };
+}
+
+// ==================== Provider 定义 ====================
+
+export const PROVIDERS = {
+  // ---------- 文本:作曲填词 / 分镜 / 口播稿 ----------
+  chat: {
+    id: 'chat',
+    kind: 'chat',
+    label: '大语言模型(作曲填词 / 分镜 / 口播稿)',
+    available: true,
+    note: 'POST /v1/chat/completions,实测可用',
+    defaultModel: () => overrides('chat').model || process.env.FEME_CHAT_MODEL || 'deepseek-v4-flash',
+    modelCandidates: ['deepseek-v4-flash', 'doubao-seed-2-1-pro', 'deepseek-v4-pro', 'doubao-seed-2-0-pro-260215'],
+    async probe({ deep = false } = {}) {
+      const model = this.defaultModel();
+      const r = await probeModelList(model);
+      if (!r.available || !deep) return { ...r, model };
+      const t0 = Date.now();
+      try {
+        await chat({ messages: [{ role: 'user', content: 'ping' }], max_tokens: 4, model });
+        return { available: true, model, detail: `真实调用成功(${Date.now() - t0}ms)` };
+      } catch (e) {
+        return { available: false, model, detail: `真实调用失败:${e.message.split('\n')[0]}` };
+      }
+    },
+    async call({ messages, system, temperature, max_tokens, json }) {
+      return chat({ messages, system, temperature, max_tokens, json, model: this.defaultModel() });
+    },
+  },
+
+  // ---------- 图像:场景 / 人物关键帧 ----------
+  image: {
+    id: 'image',
+    kind: 'image',
+    label: '文生图(场景 & 人物关键帧)',
+    available: true,
+    note: 'POST /v1/images/generations,实测可用(返回 b64_json)',
+    defaultModel: () => overrides('image').model || process.env.FEME_IMAGE_MODEL || 'gpt-image-2.5-sunburst',
+    modelCandidates: ['gpt-image-2.5-sunburst', 'doubao-seedream-4-0-250828'],
+    async probe({ deep = false } = {}) {
+      const model = this.defaultModel();
+      const r = await probeModelList(model);
+      // 图像探活不做 deep(每张都要计费),改为说明
+      return { ...r, model, detail: r.available ? `${r.detail}(deep 会真的生成图片并计费,故默认跳过)` : r.detail };
+    },
+    async call({ prompt, size = '1024x1024', n = 1, refImage }) {
+      const { generateImage } = await import('./api.mjs');
+      const model = this.defaultModel();
+      return generateImage({ prompt, size, n, refImage, model });
+    },
+  },
+
+  // ---------- 音乐:AI 作曲 ----------
+  music: {
+    id: 'music',
+    kind: 'music',
+    label: 'AI 音乐生成(Suno 兼容通道)',
+    available: false, // ← 实测:渠道未开通
+    note: 'POST /suno/submit/music + POST /suno/fetch —— 端点存在,渠道未开通(返回 Task request failed)',
+    defaultModel: () => overrides('music').model || process.env.FEME_MUSIC_MODEL || 'chirp-v3-5',
+    endpoint: () => overrides('music').endpoint || '/suno/submit/music',
+    fetchPath: () => overrides('music').fetchPath || '/suno/fetch',
+    hint:
+      '音乐通道未开通。可选方案:\n' +
+      '  A) 等平台开通 Suno 渠道后,本命令无需改动即可用;\n' +
+      '  B) 自建/对接其它音乐服务,用 FEME_MUSIC_ENDPOINT 指向兼容端点;\n' +
+      '  C) 手工把成品音频放到 work/audio/ 下,再用 --from 跳过本步继续走后续流程。',
+    async probe({ deep = true } = {}) {
+      const r = await probeEndpoint(this.endpoint(), {
+        method: 'GET',
+        timeout: 15_000,
+      });
+      if (!r.exists) return { available: false, detail: r.detail, hint: this.hint };
+      if (!deep) return { available: null, detail: r.detail + '(未做真实提交;--deep 才真提交)' };
+
+      const post = await probeEndpoint(this.endpoint(), {
+        method: 'POST',
+        body: { prompt: 'probe', mv: this.defaultModel(), custom_mode: false, make_instrumental: true },
+        timeout: 30_000,
+      });
+      if (post.exists && !post.noChannel && post.status < 400) {
+        return { available: true, detail: '提交成功,渠道已开通' };
+      }
+      return {
+        available: false,
+        detail: post.noChannel ? post.detail : `提交失败:${post.detail}`,
+        hint: this.hint,
+      };
+    },
+    async call({ prompt, lyrics, instrumental = false, title }) {
+      if (!this.available && !process.env.FEME_FORCE_UNAVAILABLE_CHANNELS) {
+        // 先真试一次,渠道若被开通则照常走通;失败再给指引
+      }
+      const base = cfg().rootUrl;
+      const payload = {
+        prompt: prompt || '', // 曲风 / 风格描述
+        mv: this.defaultModel(), // 版本(chirp-v3-5 等)
+        title: title || undefined,
+        custom_mode: !!lyrics,
+        make_instrumental: !!instrumental,
+      };
+      if (lyrics) {
+        // Suno 自定义模式:歌词 + 标题
+        payload.lyrics = typeof lyrics === 'string' ? lyrics : String(lyrics);
+        payload.prompt = prompt || '';
+      }
+      try {
+        const { taskId, result } = await runAsyncTask({
+          submitPath: this.endpoint(),
+          listPath: this.fetchPath(),
+          submitBase: base,
+          fetchBase: base,
+          payload,
+          taskLabel: '音乐生成',
+          interval: 6000,
+          maxWait: 480_000,
+          pick: (raw) => ({
+            status: raw?.status || 'running',
+            progress: raw?.progress,
+            error: raw?.fail_reason || raw?.error,
+            result: raw,
+          }),
+        });
+        return { taskId, result };
+      } catch (e) {
+        if (e instanceof ProviderUnavailableError || /channel|401|403|404|503|502/i.test(String(e.message))) {
+          throw new ProviderUnavailableError(`音乐通道调用失败:${e.message.split('\n')[0]}`, {
+            endpoint: `${base}${this.endpoint()}`,
+            status: e.status,
+            hint: this.hint,
+            cause: e,
+          });
+        }
+        throw e;
+      }
+    },
+  },
+
+  // ---------- 视频:图生视频 ----------
+  video: {
+    id: 'video',
+    kind: 'video',
+    label: '图生视频(B-roll 空镜 / 人物演唱镜头)',
+    available: false, // ← 实测:模型在列,渠道未开通
+    note: 'POST /v1/video/generations(异步 task_id)—— 实测返回 503 No available channel for model',
+    defaultModel: () => overrides('video').model || process.env.FEME_VIDEO_MODEL || 'doubao-seedance-1-0-pro',
+    modelCandidates: ['doubao-seedance-1-0-pro', 'doubao-seedance-1-0-lite', 'kling-v1'],
+    endpoint: () => overrides('video').endpoint || '/video/generations',
+    fetchPath: () => overrides('video').fetchPath || '/video/generations',
+    hint:
+      '视频通道未开通(模型在清单里但无可用渠道)。可选方案:\n' +
+      '  A) 平台开通后本命令直接用;\n' +
+      '  B) FEME_VIDEO_MODEL 换一个已开通渠道的视频模型;\n' +
+      '  C) 用「静帧 + Ken Burns 运镜」兜底(fmode-mv video --fallback-still),\n' +
+      '     把关键帧做成有推拉摇移的动态镜头,先出片、后替换真视频。',
+    async probe({ deep = false } = {}) {
+      const model = this.defaultModel();
+      const list = await probeModelList(model);
+      const r = await probeEndpoint(this.endpoint(), {
+        method: 'POST',
+        body: { model, prompt: 'probe', duration: 4 },
+        timeout: 25_000,
+      });
+      if (!r.exists) {
+        return { available: false, model, detail: `视频端点不存在:${r.detail}`, hint: this.hint };
+      }
+      if (r.noChannel) {
+        return {
+          available: false,
+          model,
+          detail: `模型 ${model} 无可用渠道:${r.detail.replace(/^端点存在但渠道未开通(HTTP \d+):/, '')}`,
+          hint: this.hint,
+        };
+      }
+      if (r.status >= 400) {
+        return { available: false, model, detail: `提交失败(HTTP ${r.status})`, hint: this.hint };
+      }
+      return { available: true, model, detail: '提交成功,渠道已开通' };
+    },
+    async call({ image, prompt, duration = 5, size = '1280x720' }) {
+      const model = this.defaultModel();
+      const payload = { model, prompt, duration, size, image };
+      const { taskId, result } = await runAsyncTask({
+        submitPath: this.endpoint(),
+        listPath: this.fetchPath(),
+        payload,
+        taskLabel: '图生视频',
+        interval: 8000,
+        maxWait: 900_000,
+        pick: (raw) => ({
+          status: raw?.status || 'running',
+          progress: raw?.progress,
+          error: raw?.fail_reason || raw?.error?.message || raw?.error,
+          result: raw,
+        }),
+      });
+      return { taskId, result };
+    },
+  },
+
+  // ---------- 唇形驱动 ----------
+  lipsync: {
+    id: 'lipsync',
+    kind: 'lipsync',
+    label: '唇形驱动(口型对齐人声)',
+    available: false, // ← 平台暂无通道
+    note: '平台暂无通道,留接口 + 外部服务对接说明',
+    defaultModel: () => overrides('lipsync').model || process.env.FEME_LIPSYNC_MODEL || '',
+    endpoint: () => overrides('lipsync').endpoint || '/lipsync/generations',
+    hint:
+      '唇形驱动通道平台暂无。可选方案:\n' +
+      '  A) 自建/对接 SadTalker / Wav2Lip / LatentSync,用 FEME_LIPSYNC_ENDPOINT 指向你的服务;\n' +
+      '  B) 用「演唱镜头」替代:关键帧 + 图生视频做表情/头部微动,不严格对口型(成片观感可接受);\n' +
+      '  C) 只走空镜 B-roll + 字幕,完全不做唇形(纯音乐 MV 常见做法)。',
+    async probe() {
+      const ep = this.endpoint();
+      const r = await probeEndpoint(ep, { method: 'POST', body: { image: 'probe', audio: 'probe' }, timeout: 20_000 });
+      if (!r.exists) {
+        return { available: false, detail: `平台无此通道:${r.detail}`, hint: this.hint };
+      }
+      if (r.noChannel || r.status >= 400) {
+        return { available: false, detail: `端点存在但不可用:${r.detail}`, hint: this.hint };
+      }
+      return { available: true, detail: '端点可用' };
+    },
+    async call() {
+      throw new ProviderUnavailableError('唇形驱动通道不可用', { endpoint: this.endpoint(), hint: this.hint });
+    },
+  },
+
+  // ---------- TTS:口播配音 ----------
+  tts: {
+    id: 'tts',
+    kind: 'tts',
+    label: '语音合成(口播配音)',
+    available: false, // ← 平台暂无通道
+    note: '平台暂无通道,留接口 + 外部服务对接说明',
+    defaultVoice: () => overrides('tts').voice || process.env.FEME_TTS_VOICE || 'zh-CN-XiaoxiaoNeural',
+    defaultModel: () => overrides('tts').model || process.env.FEME_TTS_MODEL || 'tts-1',
+    endpoint: () => overrides('tts').endpoint || '/audio/speech',
+    hint:
+      'TTS 通道平台暂无。可选方案:\n' +
+      '  A) 对接 OpenAI 兼容 /v1/audio/speech:设置 FEME_TTS_ENDPOINT 与 FEME_TTS_MODEL;\n' +
+      '  B) 本地 edge-tts(免费、中文音色多):pip install edge-tts,本技能会自动探测并调用;\n' +
+      '  C) 自备配音音频放入 work/audio/voiceover.mp3,用 --from 跳过本步。',
+    async probe() {
+      // 优先探测本地 edge-tts(免费、中文音色多),再探网关端点
+      const local = await probeLocalTTS();
+      if (local.available) {
+        return { available: true, provider: 'edge-tts(local)', detail: local.detail };
+      }
+      const r = await probeEndpoint(this.endpoint(), {
+        method: 'POST',
+        body: { model: this.defaultModel(), input: 'probe', voice: this.defaultVoice() },
+        timeout: 20_000,
+      });
+      if (!r.exists) {
+        return { available: false, provider: '平台网关', detail: `平台无 TTS 通道(${r.detail});本地 edge-tts 亦未安装`, hint: this.hint };
+      }
+      if (r.noChannel || r.status >= 400) {
+        return { available: false, provider: '平台网关', detail: `TTS 端点不可用:${r.detail}`, hint: this.hint };
+      }
+      return { available: true, provider: '平台网关', detail: '端点可用' };
+    },
+    async call({ text, outPath, voice }) {
+      const local = await probeLocalTTS();
+      if (local.available) return synthLocalTTS({ text, outPath, voice: voice || this.defaultVoice() });
+      throw new ProviderUnavailableError('TTS 通道不可用', { endpoint: this.endpoint(), hint: this.hint });
+    },
+  },
+};
+
+// ==================== 本地 TTS 探测(edge-tts)====================
+
+async function probeLocalTTS() {
+  const { spawnSync } = await import('node:child_process');
+  try {
+    const r = spawnSync('edge-tts', ['--version'], { encoding: 'utf8', timeout: 10_000 });
+    if (r.status === 0) {
+      return { available: true, provider: 'edge-tts(local)', detail: `本地 edge-tts 可用:${(r.stdout || '').trim().split('\n')[0]}` };
+    }
+  } catch {
+    /* 未安装 */
+  }
+  return { available: false, provider: 'edge-tts(local)', detail: '未检测到本地 edge-tts' };
+}
+
+async function synthLocalTTS({ text, outPath, voice }) {
+  const { spawn } = await import('node:child_process');
+  const args = ['--voice', voice, '--text', text, '--write-media', outPath];
+  await new Promise((resolve, reject) => {
+    const p = spawn('edge-tts', args);
+    let err = '';
+    p.stderr.on('data', (d) => (err += d));
+    p.on('error', reject);
+    p.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`edge-tts 退出码 ${code}: ${err.slice(0, 300)}`))));
+  });
+  return { outPath, voice, provider: 'edge-tts(local)' };
+}
+
+// ==================== 注册表 API ====================
+
+export const KINDS = ['chat', 'image', 'music', 'video', 'lipsync', 'tts'];
+
+export function getProvider(kind) {
+  const p = PROVIDERS[kind];
+  if (!p) {
+    throw new Error(`未知 provider 类型「${kind}」。可用:${KINDS.join(' / ')}`);
+  }
+  return p;
+}
+
+/** 探活全部 provider(并行),返回结构化能力矩阵 */
+export async function probeAll({ deep = false } = {}) {
+  const results = await Promise.all(
+    KINDS.map(async (k) => {
+      const p = PROVIDERS[k];
+      try {
+        const r = await p.probe({ deep });
+        return { kind: k, label: p.label, available: r.available, detail: r.detail, model: r.model || p.defaultModel?.(), hint: r.hint || (r.available ? undefined : p.hint) };
+      } catch (e) {
+        return { kind: k, label: p.label, available: false, detail: `探活异常:${e.message.split('\n')[0]}`, hint: p.hint };
+      }
+    })
+  );
+  return results;
+}
+
+/**
+ * 统一调用入口。未开通通道会抛 ProviderUnavailableError(带 hint),
+ * 绝不会「假装成功」。
+ */
+export async function invoke(kind, params) {
+  const p = getProvider(kind);
+  return p.call(params);
+}
+

+ 47 - 0
package.json

@@ -0,0 +1,47 @@
+{
+  "name": "fmode-mv",
+  "version": "0.1.0",
+  "description": "纯AI音乐MV全流程技能:作曲填词 → AI音乐 → 关键帧 → 图生视频 → 唇形驱动 → 剪辑成片(产线A);口播稿 → TTS → 讲解画面 → B-roll → 成片(产线B)。零依赖纯ESM。",
+  "type": "module",
+  "main": "./lib/index.mjs",
+  "exports": {
+    ".": {
+      "import": "./lib/index.mjs",
+      "default": "./lib/index.mjs"
+    }
+  },
+  "bin": {
+    "fmode-mv": "./bin/fmode-mv.js"
+  },
+  "files": [
+    "bin/",
+    "lib/",
+    "scripts/",
+    "references/",
+    "templates/",
+    "SKILL.md",
+    "README.md",
+    "LICENSE"
+  ],
+  "keywords": [
+    "fmode",
+    "skill",
+    "mv",
+    "music",
+    "video",
+    "ai",
+    "suno",
+    "lipsync",
+    "tts",
+    "ffmpeg",
+    "storyboard"
+  ],
+  "license": "MIT",
+  "repository": {
+    "type": "git",
+    "url": "git+https://git.fmode.cn/fmode-13177980648/skill-mv.git"
+  },
+  "engines": {
+    "node": ">=18"
+  }
+}

+ 277 - 0
references/capability-matrix.md

@@ -0,0 +1,277 @@
+# 能力现状矩阵与替代方案
+
+> **本文件是 `skill-mv` 能力边界的唯一事实来源,与 `lib/providers.mjs` 保持同步。**
+> 所有结论均为 **2026-10-13 在网关 `https://api.fmode.cn/v1` 实测**得到。
+> 状态随时可能变化 —— 复测命令:
+>
+> ```bash
+> fmode-mv providers          # 轻量探活(查模型清单 + 路由真实性,不产生费用)
+> fmode-mv providers --deep   # 深度探活(会真实提交任务,可能计费)
+> ```
+
+---
+
+## 一、总览
+
+| 环节 | 端点 | 现状 | 证据 |
+|---|---|---|---|
+| 作曲填词 / 分镜 / 口播稿 | `POST /v1/chat/completions` | ✅ **可用** | 实测产出完整歌词与分镜 JSON |
+| 场景 / 人物关键帧 | `POST /v1/images/generations` | ✅ **可用** | 实测 `gpt-image-2.5-sunburst` 返回 b64_json,1792×1024 / 3MB |
+| 剪辑 / 合成 | 本地 ffmpeg | ✅ **可用** | 实测出片 25.2s / 1280×720 / 4.6MB,含烧字幕与响度归一化 |
+| AI 音乐 | `POST /suno/submit/music` + `POST /suno/fetch` | ⚠️ **端点存在,渠道未开通** | `POST` 返回 `503 {"code":"server_error","message":"Task request failed"}` |
+| 图生视频 | `POST /v1/video/generations` | ⚠️ **无可用渠道** | `503 {"code":"model_not_found","message":"No available channel for model doubao-seedance-1-0-pro ..."}` |
+| 唇形驱动 | — | ⚠️ **平台无此路由** | 网关返回前端兜底页(`HTTP 200` + `text/html`) |
+| TTS 口播配音 | `/v1/audio/speech` | ⚠️ **平台无此路由** | 同上;但**本地 edge-tts 可替代** |
+| 音轨分离 | 本地 | ◐ **ffmpeg 近似 / demucs 可选** | 单声道素材无法分离;立体声可做相位近似 |
+
+**汇总:2 条平台通道可用(chat / image)+ 本地剪辑可用。**
+
+---
+
+## 二、⚠️ 一个必须知道的探测陷阱
+
+网关**根域对未知路径返回 SPA 的前端 `index.html`**,状态码是 **`200`**,`Content-Type: text/html`。
+
+这意味着:**只看 HTTP 状态码会把不存在的端点误判为"可用"**。
+
+早期版本的探活就踩了这个坑(`/lipsync/generations`、`/audio/speech` 都显示 `HTTP 200` → 误报可用)。
+现已修正,`probeEndpoint()` 会做三重判断:
+
+```js
+// 1. Content-Type / 响应体是 HTML → 端点不存在
+if (ct.includes('text/html') || /^\s*<(doctype html|html)/i.test(text)) → 不存在
+// 2. 405 → 路由存在(方法不对)
+if (status === 405) → 存在
+// 3. 明确的渠道错误 → 端点存在但渠道未开通
+if (/no available channel|model_not_found|task request failed/i.test(msg)) → 存在但不可用
+```
+
+**如果你自己写探活脚本,务必带上这个判断**,否则会得出「TTS 可用」的错误结论。
+
+---
+
+## 三、逐项替代方案
+
+### AI 音乐 ⚠️
+
+**症状**:`POST https://api.fmode.cn/suno/submit/music` → `503 {"code":"server_error","message":"Task request failed"}`
+(端点路由存在 —— `GET` 该路径返回 `405 Method Not Allowed`)
+
+| 方案 | 操作 | 适用 |
+|---|---|---|
+| A. 等平台开通 | 无 | 通道开通后 `fmode-mv music --from work/song.json` **无需改代码**即可用 |
+| B. 自备音乐 | `fmode-mv song "主题" --music your-song.mp3` | **当前最实际**。用自己的编曲/购买的音乐 |
+| C. 对接外部 | `export FEME_MUSIC_ENDPOINT=https://your-host/suno/submit/music` | 自建或采购了 Suno 兼容服务 |
+
+**设计保证**:`lib/providers.mjs` 的 `music` provider 端点与模型名**不在代码里写死**,
+全部可被 `FEME_MUSIC_ENDPOINT` / `FEME_MUSIC_MODEL` 覆盖。
+平台开通或换服务商,**只需改环境变量**。
+
+---
+
+### 图生视频 ⚠️
+
+**症状**:`POST /v1/video/generations`
+→ `503 {"code":"model_not_found","message":"No available channel for model doubao-seedance-1-0-pro under group default (distributor)"}`
+
+模型名**在**网关 `/v1/models` 清单里,但**没有可用渠道**(这是"模型在列 ≠ 能调用"的典型)。
+
+| 方案 | 操作 | 适用 |
+|---|---|---|
+| A. 平台开通 | 无 | 开通后 `fmode-mv video --from work/storyboard.json --mode provider --force` 一键替换 |
+| B. 换已开通模型 | `export FEME_VIDEO_MODEL=<可用模型名>` | 若网关接入其它视频模型 |
+| C. **静帧 + Ken Burns 兜底** | 默认 `--mode auto` 自动启用 | **当前默认行为** |
+
+**兜底实现**(`lib/ffmpeg.mjs` 的 `kenBurns()`):
+在 2 倍画布上做 `zoompan` 缓慢推拉,按镜头序号交替推近/拉远,输出 1280×720 @30fps。
+
+**兜底产物的诚实标记**(这是本技能的设计底线):
+- 文件名带 **`.fallback`**:`shot-01.fallback.mp4`
+- `work/clips/clips.json` 里 `mode: "still"`、`fallback: true`
+- 命令输出明确打印:
+
+  ```
+  ! 注意:本次所有片段都是「静帧 + 运镜」兜底产物,不是 AI 图生视频结果。
+    !提示:N 段为兜底运镜(非真 AI 图生视频)
+  ```
+- `out/manifest.json` 记录 `fallbackCount`,成片报告里再提示一次
+
+**绝不把兜底产物说成 AI 生成视频。**
+
+---
+
+### 唇形驱动 ⚠️
+
+**症状**:网关**没有** `/lipsync/generations` 路由 —— 返回 SPA 兜底页(`HTTP 200` + `text/html`)。
+
+| 方案 | 操作 | 观感 |
+|---|---|---|
+| C. 纯空镜 + 字幕 | 分镜里不标 `lipSync` | 纯音乐 MV 最常见做法,零风险 ✅ |
+| B. 表情微动镜头 | 演唱镜头走图生视频(表情/头部微动),不严格对口型 | 可接受 |
+| **A. 自建服务** | 部 Wav2Lip / SadTalker / LatentSync → `export FEME_LIPSYNC_ENDPOINT=https://your-host/lipsync` | 最好 |
+
+**自建服务的接口约定**(技能按此调用):
+
+```http
+POST <FEME_LIPSYNC_ENDPOINT>
+Content-Type: application/json
+Authorization: Bearer <token>
+
+{ "image": "<人脸图片 URL 或 data:image/png;base64,...>", "audio": "<音频 URL>", "model": "<FEME_LIPSYNC_MODEL>" }
+```
+
+期望响应:任何包含视频 URL 的结构(技能会递归扫描 `video_url` / `url` 等字段),例如:
+
+```json
+{ "data": { "video_url": "https://cdn.example.com/out.mp4" } }
+```
+
+**降低依赖的建议**:
+- 分镜里 `lipSync: true` 的镜头**全片 ≤ 4 个**(产线 B ≤ 2 个)。
+- `talking`/`perf` 镜头用中景而非正脸特写,口型不匹配不易察觉。
+
+---
+
+### TTS 口播配音 ⚠️
+
+**症状**:网关**没有** `/v1/audio/speech` 路由 —— 同样返回 SPA 兜底页。
+
+| 方案 | 操作 | 说明 |
+|---|---|---|
+| **A. 本地 edge-tts**(推荐) | `pip install edge-tts` | **免费**,中文音色多。技能**自动探测**,装完即用,无需配置 |
+| B. 对接外部 | `export FEME_TTS_ENDPOINT=https://your-host/v1/audio/speech` | OpenAI 兼容接口 |
+| C. 自备配音 | `--voice my.mp3` | 真人录音也行 |
+
+**探测优先级**(`tts.probe()`):**本地 edge-tts 优先**,其次才探网关。
+安装后 `fmode-mv tts --probe` 返回:
+
+```
+状态      可用
+提供方    edge-tts(local)
+```
+
+**常用中文音色**:
+
+| 音色 | 特征 |
+|---|---|
+| `zh-CN-XiaoxiaoNeural` | 女声,自然亲和(默认) |
+| `zh-CN-YunxiNeural` | 男声,年轻 |
+| `zh-CN-YunjianNeural` | 男声,沉稳 |
+| `zh-CN-XiaoyiNeural` | 女声,活泼 |
+
+---
+
+### 音轨分离 ◐
+
+**不是"未开通",是"精度分级"**:
+
+| 级别 | 依赖 | 效果 | 命令 |
+|---|---|---|---|
+| 高质量 | `demucs`(`pip install demucs`) | 真·分离,人声/伴奏/鼓/贝斯分轨 | `fmode-mv separate x.mp3 --method demucs` |
+| **兜底** | 仅 ffmpeg | **近似**:侧信号取伴奏、中置取人声 | `fmode-mv separate x.mp3`(默认) |
+
+**兜底的物理限制(必须如实告知用户)**:
+
+1. **仅对立体声有效** —— 单声道素材脚本会**直接报错**,不会给你假结果。
+2. **是相位近似,不是分离** —— 只对「人声居中、乐器分列左右」的老式混音有效;
+   现代宽混音基本无效,人声/伴奏都会残留串扰。
+3. 脚本会明确打印:`! 未检测到 demucs,使用 ffmpeg 相位兜底方案(近似分离,非真·分离)`,
+   并在返回结构里带 `note` 字段。
+
+---
+
+## 四、provider 覆盖机制(不改代码换通道)
+
+`lib/providers.mjs` 是唯一的通道注册表。任何 provider 的模型名/端点都可被覆盖:
+
+### 方式 1:环境变量
+
+```bash
+export FEME_MUSIC_MODEL=chirp-v4-0
+export FEME_MUSIC_ENDPOINT=https://your-host/suno/submit/music
+export FEME_VIDEO_MODEL=kling-v1
+export FEME_VIDEO_ENDPOINT=https://your-host/v1/video/generations
+export FEME_LIPSYNC_ENDPOINT=https://your-host/lipsync
+export FEME_TTS_ENDPOINT=https://your-host/v1/audio/speech
+export FEME_TTS_VOICE=zh-CN-YunxiNeural
+export FEME_CHAT_MODEL=doubao-seed-2-1-pro
+export FEME_IMAGE_MODEL=gpt-image-2.5-sunburst
+```
+
+### 方式 2:身份文件的 `providers` 段
+
+```jsonc
+// /opt/data/fmode-identity.json
+{
+  "newapi_token": "...",
+  "providers": {
+    "music": { "model": "chirp-v4-0", "endpoint": "/suno/submit/music" },
+    "video": { "model": "kling-v1" },
+    "tts":   { "voice": "zh-CN-YunxiNeural" }
+  }
+}
+```
+
+> ⚠️ 身份文件含凭据,**不要提交进 Git**(`.gitignore` 已排除 `*identity*.json`)。
+
+**覆盖优先级**:环境变量 > 身份文件 `providers` 段 > provider 内置默认值。
+
+---
+
+## 五、复测脚本
+
+通道状态会变。建议每次开工前跑一次:
+
+```bash
+fmode-mv providers          # 轻量:查 /v1/models + 路由真实性 + SPA 兜底判断,零费用
+fmode-mv doctor             # 全量自检:node/ffmpeg/凭据/demucs/各通道
+fmode-mv providers --deep   # 深度:真实提交任务(music 会消耗额度,image 不做 deep)
+```
+
+**预期输出(2026-10-13 基线)**:
+
+```
+chat     ✅ 可用  大语言模型(作曲填词 / 分镜 / 口播稿)
+image    ✅ 可用  文生图(场景 & 人物关键帧)
+music    ◐ 未知  AI 音乐生成(Suno 兼容通道)
+video    ⚠️  未开通  图生视频(B-roll 空镜 / 人物演唱镜头)
+lipsync  ⚠️  未开通  唇形驱动(口型对齐人声)
+tts      ⚠️  未开通  语音合成(口播配音)
+汇总: 2/6 通道可用
+```
+
+---
+
+## 六、本技能的诚实性承诺
+
+针对"未开通通道"这一现实,本技能遵守三条硬规则:
+
+1. **不静默失败** —— 调用未开通通道必抛 `ProviderUnavailableError`,
+   携带 `hint` 字段,打印**可操作方案**(换通道 / 对接外部 / 用现成素材)。
+2. **不伪造产物** —— 绝不写入空的或占位的音频/视频文件冒充生成结果。
+   兜底产物一律带 `.fallback` 后缀与 `fallback: true` 标记。
+3. **不隐藏降级** —— 端到端编排的最终报告里,每一步的状态
+   (`ok` / `fallback` / `unavailable` / `skipped` / `failed`)逐条列出,
+   未完成项单独成段列出原因。
+
+```
+════════════════════════════════════════════════════════════════
+  产线A · AI 单曲 MV · 执行报告
+════════════════════════════════════════════════════════════════
+  ✅ 作曲填词        《末班车还亮着》
+  ✅ 分镜脚本        6 镜 / 36s
+  ⚠️  AI 音乐        通道未开通
+  ⏭️  音轨分离        无音乐文件
+  ✅ 关键帧          成功 6 / 失败 0
+  ◐  图生视频        Ken Burns 兜底:6 段
+  ⚠️  唇形驱动        2 个待处理镜头未处理
+  ✅ 剪辑合成        36.0s / 4.6MB
+────────────────────────────────────────────────────────────────
+  成片: out/mv.mp4
+  未完成项:
+   · AI 音乐: 通道未开通
+   · 唇形驱动: 2 个待处理镜头未处理
+════════════════════════════════════════════════════════════════
+```
+
+**这就是本技能对"诚实"的落地方式:让用户一眼看清哪些是真 AI 生成、哪些是降级替代。**

+ 295 - 0
references/pipeline-a-song-mv.md

@@ -0,0 +1,295 @@
+# 产线 A · AI 单曲 MV 完整 SOP
+
+> 从一句主题到成片,全程零实拍。
+> 本文是「照着敲就能跑」的操作手册,含每步命令、预期产物、避坑点。
+
+---
+
+## 零、先确认环境
+
+```bash
+cd skill-mv
+fmode-mv doctor          # node / ffmpeg / 凭据 / 通道
+fmode-mv providers       # 能力现状(哪条通道能用)
+```
+
+**开工前必须知道**:截至 v0.1.0,平台只有 **chat(文案)** 与 **image(关键帧)** 两条通道开通。
+`music` / `video` / `lipsync` 未开通。这不是技能的问题,是网关渠道的问题。
+处理方式见 `references/capability-matrix.md`,本文每步都标了「通道不通时怎么办」。
+
+---
+
+## 一、产线全景
+
+```
+① 作曲填词      [chat ✅]   → work/song.json
+② 分镜脚本      [chat ✅]   → work/storyboard.json
+③ AI 音乐       [music ⚠️]  → work/audio/music.mp3
+④ 音轨分离      [本地 ◐]    → work/audio/vocals.wav + instrumental.wav
+⑤ 关键帧        [image ✅]  → work/frames/shot-XX.png
+⑥ 图生视频      [video ⚠️]  → work/clips/shot-XX.mp4
+⑦ 唇形驱动      [lipsync ⚠️] → work/lipsync/lip-XX.mp4
+⑧ 剪辑合成      [本地 ✅]   → out/mv.mp4
+```
+
+一条命令跑完全程:
+
+```bash
+fmode-mv song "深夜末班地铁上的城市白领" --genre "city pop" --mood "孤独但温暖"
+```
+
+下面逐步拆解,方便分段调试与省钱。
+
+---
+
+## 二、① 作曲填词
+
+```bash
+fmode-mv lyrics "深夜末班地铁上的城市白领,加班后的疲惫与微小的温暖" \
+  --genre "city pop" \
+  --mood "孤独但温暖"
+```
+
+**产物** `work/song.json`:
+
+```jsonc
+{
+  "title": "末班车还亮着",
+  "genre": "城市流行 City Pop",
+  "mood": "孤独、温暖、夜色、治愈",
+  "bpm": 92,
+  "structure": ["Intro","Verse 1","Pre-Chorus","Chorus", ...],
+  "lyrics": "[Intro]\n(器乐)\n\n[Verse 1]\n写字楼的灯 一盏一盏熄了\n...",
+  "stylePrompt": "city pop, 92 bpm, warm analog synth pads, electric piano chords, ... female vocal ..."
+}
+```
+
+**要点**:
+- `structure` 的结构标记是给音乐模型的分段指令,**不要删**——Suno 类模型靠它排段落。
+- `stylePrompt` 是英文的,直接投给音乐模型;改曲风就改这里。
+- 命令末尾会自动做歌词自检(有没有 Verse/Chorus 标记、正文够不够长、有没有 stylePrompt),
+  有问题会 `! 歌词自检提示:...`,据此重跑或手改 JSON。
+
+**避坑**:
+- 主题写太抽象(如「爱情」)→ 歌词会很空。给具体场景(「深夜便利店买热豆浆」)效果最好。
+- 想要固定时长,用 `--style "控制在 2 分 30 秒,副歌重复 3 次"`。
+
+---
+
+## 三、② 分镜脚本
+
+```bash
+fmode-mv storyboard --from work/song.json --shots 8 --per-shot 6
+```
+
+**产物** `work/storyboard.json`(下游全部消费它)+ `work/storyboard.md`(人读版表格)。
+
+**关键字段**:
+
+| 字段 | 作用 |
+|---|---|
+| `appearanceAnchor` | ★主角外貌锚点(英文)。每个含人物的镜头都会原样复述它 → **人物一致性全靠这个** |
+| `styleKeywords` | 全片统一影像风格,拼进每张关键帧 prompt |
+| `shots[].imagePrompt` | 英文,可独立投喂文生图 |
+| `shots[].videoPrompt` | 英文运镜描述,投给图生视频 |
+| `shots[].lipSync` | 是否做唇形驱动 |
+
+**避坑(实测踩过的)**:
+
+1. **镜头数跑飞**:早期版本模型会给你 30 个 36 秒的镜头。现在提示词里加了硬约束
+   (`--shots` 为硬性约束、单镜钳制在 3~10 秒),且 `normalizeStoryboard` 会二次夹取。
+   若看到 `! N 个镜头的时长超出合理区间已被夹取`,说明模型不听话,已被自动纠正。
+2. **模型是推理型的**:`deepseek-v4-flash` 会把大量 token 花在推理上,8192 token 全被推理吃掉、
+   JSON 被截断。技能已内置**自动升配**(翻倍重试到 24k~32k)+ **修复式重试**。
+   若还失败,换非推理模型:`--model doubao-seed-2-1-pro`。
+3. **人物一致性**:不要手工改 `appearanceAnchor`,除非你同时接受整片人物变脸。
+   想换主角,改锚点后**必须重生成全部关键帧**。
+
+**只做几镜试跑**(省钱):
+
+```bash
+fmode-mv storyboard --from work/song.json --shots 3
+fmode-mv keyframe   --from work/storyboard.json --limit 3
+```
+
+---
+
+## 四、③ AI 音乐 ⚠️ 通道未开通
+
+```bash
+fmode-mv music --from work/song.json
+```
+
+**现状实测**:`POST /suno/submit/music` 返回 **503 `Task request failed`** —— 端点存在,渠道未开通。
+命令会**真试一次**,失败后给出可操作方案,**不会伪造 mp3**。
+
+**三条替代路线**:
+
+| 方案 | 做法 |
+|---|---|
+| A. 等平台开通 | 开通后原命令无需改动即可用 |
+| B. 自备音乐 | 用你的音频直接进后续流程:`--music your-song.mp3` |
+| C. 对接外部 | `export FEME_MUSIC_ENDPOINT=https://...` 指向兼容端点 |
+
+**自备音乐时的注意**:
+- 必须是**成品混音**(有人声+伴奏),否则第 ④ 步分离没意义。
+- 时长最好与分镜总时长接近;`assemble` 会以画面轨时长为准,音频超出会被 `-shortest` 截断。
+
+---
+
+## 五、④ 人声 / 伴奏分离 ◐
+
+```bash
+fmode-mv separate work/audio/music.mp3
+```
+
+**两级策略,脚本会自动选**:
+
+1. **demucs**(若已安装)→ 真·分离,质量高。安装:`pip install demucs`
+2. **ffmpeg 相位兜底**(默认)→ 侧信号取伴奏、中置取人声。
+   ⚠️ 这是**近似**,人声/伴奏都会残留串扰。**脚本会明确打印这个警告。**
+
+**避坑**:
+- **单声道素材无法分离** → 脚本直接报错并告诉你装 demucs,不会给你一个假结果。
+- 分离出的 `vocals.wav` 主要给第 ⑦ 步唇形驱动当驱动音频用;纯音乐 MV 其实可以跳过这步。
+- 兜底方案对「人声居中、乐器左右分布」的老式混音效果尚可;现代宽混音基本无效。
+
+---
+
+## 六、⑤ 场景 & 人物关键帧 ✅
+
+```bash
+fmode-mv keyframe --from work/storyboard.json              # 全部镜头
+fmode-mv keyframe --from work/storyboard.json --shot 3     # 只做第 3 镜
+fmode-mv keyframe --from work/storyboard.json --dry-run    # 只看 prompt,不花钱
+```
+
+**产物**:`work/frames/shot-XX.png` + `work/frames/keyframes.json`(含每张的 prompt,便于复现)。
+
+**要点**:
+- 实测可用:`gpt-image-2.5-sunburst`,`--tier large` 出 1792×1024,约 3MB/张。
+- **先 `--dry-run` 看 prompt**,确认人物锚点进去了再花钱。这是最省钱的习惯。
+- 风格统一靠 `styleKeywords`(自动拼入);不一致时手工把它写具体,
+  例:`"neo-noir city pop, sodium orange and cyan teal palette, 35mm film grain"`。
+
+**避坑**:
+- **首镜最重要**:第 1 镜定调,人物/场景一致性从它开始。先只跑 `--shot 1`,满意了再批量。
+- 图里的**文字/招牌** AI 会写成乱码或错字。若在意,在 prompt 里避免要求文字招牌,
+  或在 `styleKeywords` 加 `no text, no signage`。
+- 图生视频需要**同一角色不同机位**时,用 `--anchor` 显式传锚点,别指望模型自己记住。
+
+---
+
+## 七、⑥ 图生视频 ⚠️ 通道未开通
+
+```bash
+fmode-mv video --from work/storyboard.json
+```
+
+**现状实测**:`POST /v1/video/generations` 返回 **503 `No available channel for model doubao-seedance-1-0-pro`**。
+模型在清单里,但没有可用渠道。
+
+**`--mode` 三种模式**:
+
+| 模式 | 行为 |
+|---|---|
+| `auto`(默认) | 先探活;通就走真通道,不通**自动降级**为 Ken Burns 兜底 |
+| `provider` | 只走真通道,不通即失败(用于通道开通后强制验证) |
+| `still` | 强制用关键帧做 Ken Burns 运镜 |
+
+**兜底是什么**:把关键帧做成 2 倍画布上的缓慢推拉(`zoompan`),按镜头序号交替推近/拉远。
+产物文件名是 **`shot-XX.fallback.mp4`** —— 一眼能看出这不是真 AI 生成视频。
+
+**避坑**:
+- 兜底片段**体积偏大**(1080p 十余 MB/段),因为静态图用低 CRF 编码。要压小可加 `--tier standard` 生成小图。
+- 单段**必须 5~8 秒**(技能已把分镜时长钳制在此区间)——真通道一般也就支持 4~10 秒,
+  长镜头要靠多段拼接,而不是让单段变长。
+- 真通道开通后**一键替换**:
+  ```bash
+  fmode-mv video --from work/storyboard.json --mode provider --force
+  ```
+
+---
+
+## 八、⑦ 唇形驱动 ⚠️ 平台暂无通道
+
+```bash
+fmode-mv lipsync --probe    # 先看通道状态
+```
+
+**现状**:网关**没有** `/lipsync/generations` 这条路由(返回前端兜底页)。
+
+**三条替代路线**(按推荐度排):
+
+| 方案 | 做法 | 观感 |
+|---|---|---|
+| C. 纯空镜 + 字幕 | 不标 `lipSync`,全片走 B-roll | 纯音乐 MV 最常见,零风险 |
+| B. 表情微动镜头 | 演唱镜头走图生视频(表情/头部微动),不严格对口型 | 成片观感可接受 |
+| A. 自建服务 | 部 Wav2Lip / SadTalker / LatentSync,`export FEME_LIPSYNC_ENDPOINT=https://your-host/lipsync` | 最好,但要部署 |
+
+**自建对接的请求约定**(技能会这样调你的服务):
+
+```jsonc
+POST <FEME_LIPSYNC_ENDPOINT>
+{ "image": "<人脸图片URL或base64>", "audio": "<音频URL>", "model": "..." }
+// 期望返回:{ "data": { "video_url": "https://..." } } 或任何含视频 URL 的结构
+```
+
+**避坑**:
+- 唇形镜头**全片别超过 4 个**:每个都需要人脸正脸关键帧 + 人声干声,成本和翻车率都高。
+- 分镜里的 `lipSync: true` 只对 `perf`/`talking` 类型生效,给 `broll` 标了也会被忽略。
+
+---
+
+## 九、⑧ 剪辑合成 ✅
+
+```bash
+fmode-mv assemble \
+  --from work/storyboard.json \
+  --music work/audio/music.mp3 \
+  --beat-sync
+```
+
+**这一步做的事(全在本地 ffmpeg)**:
+
+1. 按 `shot-XX` 序号收集片段(缺 `shot-XX.mp4` 时自动用 `shot-XX.fallback.mp4` 顶上)
+2. 统一分辨率/帧率后拼接
+3. `--beat-sync`:能量包络节拍检测 → 把镜头时长吸附到节拍点
+4. 从分镜的 `subtitle` 字段生成 SRT,烧硬字幕
+5. 混音(音乐 + 可选配音/背景乐),片尾淡出
+6. 响度归一化到 I=-14 LUFS(两遍 loudnorm)
+
+**产物**:`out/mv.mp4` + `out/manifest.json`(含兜底片段数、BPM、音轨信息)。
+
+**避坑**:
+
+- **卡点检测对纯音无效**。实测用正弦波测出「0 个节拍点」——
+  脚本会明确告诉你「未检出节拍点,跳过卡点对齐」,**不会假装对齐了**。
+  要卡点必须有鼓点/瞬态的成品编曲。
+- **字幕字号自适应**:按分辨率算 `fontSize = h/36`,改分辨率不用手调。
+- **字幕烧入失败不会中断成片**:会降级为「成片不带字幕,SRT 文件仍在 work/」并打印警告。
+  常见原因是字体缺失,装 `fonts-noto-cjk` 即可。
+- 片段缺失或损坏时 `assemble` 会直接报错告诉你缺哪段,不会给你一个静默残缺的成片。
+
+---
+
+## 十、端到端速查
+
+```bash
+# 全自动(能跑的都跑,跑不了的如实跳过并降级)
+fmode-mv song "主题" --genre "city pop"
+
+# 分段调试(推荐首次使用)
+fmode-mv lyrics "主题" --genre "city pop"
+fmode-mv storyboard --from work/song.json --shots 6
+fmode-mv keyframe --from work/storyboard.json --dry-run    # 先看 prompt
+fmode-mv keyframe --from work/storyboard.json --shot 1     # 只做首镜
+fmode-mv keyframe --from work/storyboard.json              # 满意后批量
+fmode-mv video --from work/storyboard.json                 # 自动兜底
+fmode-mv assemble --from work/storyboard.json --music your-song.mp3 --beat-sync
+```
+
+**开工前的三个好习惯**:
+1. `--dry-run` 看 prompt,别一上来就批量烧钱。
+2. 先 `--limit 2` 跑通全链路,再放开全部。
+3. 每步的产物 JSON 都在 `work/`,不满意**只重跑那一步**,不用从头来。

+ 226 - 0
references/pipeline-b-talking.md

@@ -0,0 +1,226 @@
+# 产线 B · AI 口播讲解视频完整 SOP
+
+> 口播稿 → TTS 配音 → 讲解画面 → 场景 B-roll → 剪辑成片。全程零实拍。
+>
+> 与产线 A **共用同一套底层模块**(`chat` / `image` / `video` / `tts` / `ffmpeg`),
+> 只是编排顺序与镜头类型不同。先把 `references/pipeline-a-song-mv.md` 里的
+> 「避坑」通用部分看一遍,本文只讲产线 B 特有的。
+
+---
+
+## 一、产线全景
+
+```
+① 口播稿        [chat ✅]    → work/script.json + work/script.md
+② TTS 配音      [tts ⚠️]     → work/audio/voiceover.mp3
+③ 讲解分镜      [chat ✅]    → work/storyboard.json
+④ 关键帧        [image ✅]   → work/frames/shot-XX.png
+⑤ 讲解画面/B-roll [video ⚠️] → work/clips/shot-XX.mp4
+⑥ 唇形(可选)    [lipsync ⚠️]
+⑦ 剪辑合成      [本地 ✅]    → out/talk.mp4
+```
+
+一条命令跑完:
+
+```bash
+fmode-mv talk "三分钟讲清楚什么是多租户数据隔离" --duration 90
+```
+
+---
+
+## 二、① 口播稿
+
+```bash
+fmode-mv talk "三分钟讲清楚什么是多租户数据隔离" \
+  --duration 90 \
+  --audience "中小企业技术负责人" \
+  --points "痛点,方案,成本对比,落地建议"
+```
+
+**产物** `work/script.json` + `work/script.md`(可直接改)。
+
+```jsonc
+{
+  "title": "数据隔离,到底怎么隔离?",
+  "durationSec": 90,
+  "hook": "你以为你的数据是私有的?其实……",
+  "cta": "想搞清楚自己的架构,评论区聊。",
+  "hostAnchor": "Asian man in his 30s, short neat black hair, ...",
+  "sections": [{ "name": "开场钩子", "text": "..." }],
+  "fullText": "完整口播稿(TTS 用这个)"
+}
+```
+
+**要点**:
+- **字数按 3.6 字/秒估算**:90 秒 ≈ 324 字。写多了 TTS 会赶,画面也来不及切。
+- `hostAnchor` 是讲者外貌锚点,作用与产线 A 的 `appearanceAnchor` 完全一样。
+- 已有稿子直接给文件,跳过自动撰写:
+  ```bash
+  fmode-mv talk --script my-script.md --duration 120
+  ```
+  (会按 `--duration` 切分镜;稿子是 md/txt 均可,会自动剥掉 markdown 标记)
+
+**避坑**:
+- **口播稿里的数字要写成能读出来的形式**:「30%」读作「百分之三十」,
+  「API」在中文 TTS 里可能被念成字母,改成「接口」更稳。技能已在提示词里要求,但改稿时注意保持。
+- 稿子改了**必须重跑分镜**(第 ③ 步),否则字幕和画面对不上。
+
+---
+
+## 三、② TTS 配音 ⚠️ 平台暂无通道
+
+```bash
+fmode-mv tts --probe                                        # 先看有没有得用
+fmode-mv tts --from work/script.md --voice zh-CN-YunxiNeural
+```
+
+**现状**:网关**没有** TTS 路由(`/v1/audio/speech` 返回前端兜底页)。
+
+**三条替代路线**:
+
+| 方案 | 做法 | 说明 |
+|---|---|---|
+| **A. 本地 edge-tts**(推荐) | `pip install edge-tts` | 免费、中文音色多;技能会**自动探测并使用**,无需改代码 |
+| B. 对接外部 OpenAI 兼容服务 | `export FEME_TTS_ENDPOINT=https://your-host/v1/audio/speech` | 需自建或采购 |
+| C. 自备配音 | `--voice my-voiceover.mp3` 或直接 `fmode-mv talk --voice my.mp3` | 真人配音也行 |
+
+**安装 edge-tts 后**,`fmode-mv tts --probe` 会显示:
+
+```
+状态      可用
+提供方    edge-tts(local)
+```
+
+常用中文音色:`zh-CN-XiaoxiaoNeural`(女,默认)、`zh-CN-YunxiNeural`(男,年轻)、
+`zh-CN-YunjianNeural`(男,沉稳)、`zh-CN-XiaoyiNeural`(女,活泼)。
+
+**避坑**:
+- edge-tts 首次运行需要联网拉音色列表,离线环境会失败。
+- 配音时长是**后面所有步骤的基准**:分镜时长、字幕时间轴都以配音实际时长为准。
+  所以**先配音、后分镜**(本产线的顺序就是这么排的)。
+
+---
+
+## 四、③ 讲解分镜
+
+```bash
+fmode-mv storyboard --script work/script.md --duration 90 --shots 8
+```
+(`fmode-mv talk` 内部会自动调用这一步)
+
+**与产线 A 分镜的区别**:
+
+| | 产线 A | 产线 B |
+|---|---|---|
+| 镜头类型 | `broll` / `perf` | `broll` / `talking` |
+| 新增字段 | — | `narration`(对应口播原文) |
+| 字幕来源 | 歌词 | `subtitle`(≤20 字,单行) |
+| 节奏依据 | 音乐节拍 | 口播语义分段 |
+
+**要点**:
+- **B-roll 要占多数**。讲者镜头(`talking`)越多,没有唇形驱动时画面越尴尬。
+  提示词里已要求 `talking` 镜头的 `lipSync` 总数 ≤ 3。
+- 每个镜头都要有 `narration`,这是**字幕与画面同步的依据**。
+
+**避坑**:
+- 讲数据/讲概念的段落,让 AI 生成「抽象概念画面」容易翻车。
+  更好的做法:在 `--points` 里把可视觉化的东西写清楚(「三个并列的方块表示租户」),
+  或者事后手工改 `work/storyboard.json` 里那几镜的 `description`/`imagePrompt` 再重生成关键帧。
+
+---
+
+## 五、④ 关键帧 ✅
+
+```bash
+fmode-mv keyframe --from work/storyboard.json --dry-run    # 先看
+fmode-mv keyframe --from work/storyboard.json
+```
+
+同产线 A。**讲者一致性靠 `appearanceAnchor`(来自 `hostAnchor`)**,逻辑完全一致。
+
+**产线 B 的额外建议**:
+- 讲者镜头**尽量用同一景别与光线**(如「中景、正面、柔和侧光」写进 `styleKeywords`),
+  这样多镜头剪在一起不会有明显的「换了个空间」的割裂感。
+- 需要「图表感」画面时,prompt 里明确写 `clean minimal infographic style, flat design, no text`
+  —— 否则 AI 会在图里塞满乱码文字。
+
+---
+
+## 六、⑤ 讲解画面 / B-roll ⚠️ 通道未开通
+
+```bash
+fmode-mv video --from work/storyboard.json
+```
+
+同产线 A,`auto` 模式会自动降级为 Ken Burns 兜底(`shot-XX.fallback.mp4`)。
+
+**产线 B 的特殊考虑**:
+- 口播视频的 `broll` 镜头用静态运镜**观感损失比 MV 小**——
+  观众注意力在听觉信息上,画面只需「不静止、不跳戏」。
+  这是产线 B 在视频通道未开通时依然能出可用成片的原因。
+- `talking` 镜头如果没有唇形驱动,用 Ken Burns 推近会**放大「嘴不动」的问题**。
+  建议:`talking` 镜头要么做唇形、要么改成**手部动作/物件特写**类画面,别做正脸大特写。
+
+---
+
+## 七、⑥ 唇形驱动(可选)⚠️
+
+同产线 A,平台暂无通道。产线 B 场景下的建议:
+
+- 讲解视频**对口型的需求比 MV 强**(观众盯着讲者看)。所以这里更值得自建 Wav2Lip/SadTalker。
+- 若不自建:把讲者镜头压到 1~2 个,且用**中景/中近景**(口型不明显),其余用 B-roll 盖过。
+
+---
+
+## 八、⑦ 剪辑合成 ✅
+
+```bash
+fmode-mv assemble \
+  --from work/storyboard.json \
+  --voice work/audio/voiceover.mp3 \
+  --bgm work/audio/bgm.mp3 \
+  --out out/talk.mp4
+```
+
+**与产线 A 的差异**:
+
+| | 产线 A | 产线 B |
+|---|---|---|
+| 主音轨 | `--music`(音量 1.0) | `--voice`(音量 1.0) |
+| 辅助音轨 | — | `--bgm`(自动压到 **0.18**) |
+| 卡点 | `--beat-sync` 用得上 | 一般不用(口播跟着语义走) |
+
+**混音规则**(`lib/ffmpeg.mjs` 的 `amix`):
+多轨时按各自 `volume` 混,背景乐默认 0.18 —— 这个值是「人声清楚、音乐有存在感」的平衡点,
+觉得音乐太响/太轻就调 `--bgm-volume 0.12` 或 `0.28`。
+
+**避坑**:
+- 配音轨短于画面时,`-shortest` 会直接截断成片 → 成片比预期短。
+  **先确认配音时长**(`fmode-mv tts` 会打印时长),再决定分镜时长。
+- 口播字幕建议**每屏 ≤ 20 字**(提示词已要求),太长会挤满画面下方。
+
+---
+
+## 九、端到端速查
+
+```bash
+# 全自动
+fmode-mv talk "主题" --duration 90 --points "要点1,要点2"
+
+# 分段(推荐)
+fmode-mv talk "主题" --duration 90          # ① 口播稿(只看 work/script.md,Ctrl-C 中断即可)
+vim work/script.md                          # ② 手工润色稿件(强烈建议)
+fmode-mv tts --from work/script.md          # ③ 配音(装 edge-tts 后可用)
+fmode-mv storyboard --script work/script.md --duration 90 --shots 8
+fmode-mv keyframe --from work/storyboard.json --dry-run
+fmode-mv keyframe --from work/storyboard.json
+fmode-mv video --from work/storyboard.json
+fmode-mv assemble --from work/storyboard.json --voice work/audio/voiceover.mp3 --bgm bgm.mp3
+```
+
+**产线 B 特有的两条经验**:
+
+1. **稿件必须人工润色一遍**。AI 写的口播稿结构没问题,但「像人说的话」这点还差一口气。
+   TTS 会把书面语读得格外生硬,改稿的性价比比调任何参数都高。
+2. **B-roll 占比决定成片观感**。在视频/唇形通道未开通的现状下,
+   B-roll 越多、讲者特写越少,成片越稳。

+ 263 - 0
references/prompt-templates.md

@@ -0,0 +1,263 @@
+# 提示词模板库
+
+> `fmode-mv` 各步骤实际使用的提示词模板,以及**手工调优时可参考的写法**。
+> 代码侧的构建器在 `lib/prompts.mjs`;本文件是给人读、给人改的版本。
+
+---
+
+## 一、作曲填词
+
+### 系统提示词(节选,见 `scripts/lyrics.mjs`)
+
+```
+你是资深华语流行音乐制作人兼作词人,为 AI 音乐生成模型(Suno 类)撰写可直接投喂的素材。
+硬性要求:
+1. 歌词必须自带结构标记,每段用方括号起头:[Intro] [Verse 1] [Pre-Chorus] [Chorus] [Bridge] [Outro]。
+2. Chorus 要朗朗上口、有记忆点,副歌句尾押韵。
+3. 只输出 JSON,不要任何解释文字、不要 markdown 代码围栏。
+4. stylePrompt 用英文逗号短语描述曲风/编曲/情绪,供音乐模型直接使用。
+5. 歌词内容必须为原创,不得抄袭任何已有歌曲的句子。
+```
+
+> **为什么强制原创**:AI 音乐模型会因为训练数据里高度雷同的句子而"撞词",
+> 一旦与已有歌曲雷同,商业发布有版权风险。这一条不能去掉。
+
+### 用户提示词骨架
+
+```
+【主题】<用户输入>
+【指定曲风】<genre>
+【情绪基调】<mood>
+【补充要求】<style>
+
+要求:
+- 歌词长度适合 2.5~3.5 分钟,段落完整(主歌2段、副歌2-3次、可含桥段)
+- 副歌必须有记忆点,句尾尽量押韵
+- 给出英文 stylePrompt(含曲风、乐器、速度、人声类型、混音质感)
+```
+
+### stylePrompt 写法参考
+
+| 维度 | 可用词 |
+|---|---|
+| 曲风 | `city pop` / `mandarin indie rock` / `chinese folk` / `synthwave` / `lo-fi hiphop` |
+| 速度 | `92 bpm` / `mid-tempo` / `slow ballad 68 bpm` |
+| 乐器 | `warm analog synth pads` / `electric piano` / `acoustic guitar arpeggio` / `guqin and strings` |
+| 人声 | `female vocal, breathy intimate delivery` / `male vocal, raspy and warm` / `instrumental` |
+| 混音 | `lush reverb` / `tape saturation` / `punchy modern mix` / `subtle vinyl noise` |
+
+**示例**(实测产出的好例子):
+
+```
+city pop, 92 bpm, warm analog synth pads, electric piano chords, soft muted brass accents,
+clean funky guitar, mellow round bass groove, laid-back brushed drums, female vocal,
+breathy intimate delivery, lush reverb, tape saturation, subtle vinyl noise,
+night city ambience, nostalgic yet comforting, melancholic but warm
+```
+
+---
+
+## 二、分镜(产线 A)
+
+### 用户提示词的关键约束
+
+```
+【镜头数量】N 个(必须控制在 N±2 个,这是硬性约束)
+【单镜时长】每镜 6 秒左右,必须在 4~8 秒之间。
+  注意:AI 图生视频单段现实上限约 8 秒,不要生成 20 秒、30 秒的镜头。
+```
+
+> **为什么要写死**:实测中模型会按「歌长 180 秒 ÷ 想法数」自由发挥,
+> 给出 30 个 36 秒的镜头 —— 这在图生视频里**物理上不可生成**。
+> 约束写在提示词里 + `normalizeStoryboard` 二次夹取,双保险。
+
+### `imagePrompt` 的黄金结构
+
+```
+[主体] + [动作/状态] + [环境] + [光线] + [镜头语言(焦段/景别/角度)] + [风格] + [画质词]
+```
+
+**含人物时**:必须把 `appearanceAnchor` 的英文版**原样复述**在主体位置。
+
+实例(本仓实测产出):
+
+```
+A 27-year-old East Asian man with slightly tousled medium-length black hair falling over his forehead,
+thin silver-rimmed glasses, an unbuttoned charcoal-grey wool suit jacket over a wrinkled light-blue
+dress shirt with a loosened dark navy tie, black slacks and scuffed leather shoes, a beige canvas tote
+bag on his shoulder; quiet, gentle, worn-out but warm presence, calm cinematic stillness, face softly
+lit by practical light
+— sitting alone in an almost empty late-night subway carriage, temple resting against the cold window
+glass, city lights streaking across his face in long horizontal smears, tie loosened, singing softly
+— close-up to medium close-up, 85mm anamorphic lens, side profile, slightly low angle
+— cyan interior carriage light against warm orange light streaks outside the glass, bokeh of empty seats
+— City pop neo-noir realism, sodium orange and cyan teal palette, 35mm film grain, photoreal, 4K
+```
+
+**注意分号分段**:`主体; 气质; 动作环境; 镜头语言; 光线氛围; 风格画质` ——
+这个写法比逗号长串更稳定,模型不会把「气质」误读成「动作」。
+
+### `videoPrompt` 的写法
+
+**只写「怎么动」,不写「画的是什么」**(内容来自关键帧):
+
+```
+Slow orbit around his head resting against the carriage window, city light streaks sliding across
+his face, then a slight push-in as he turns his eyes to camera; barely-there handheld,
+lips in sync at 92 BPM, complete within 6 seconds
+```
+
+常用运镜词:`slow push-in` / `pull back` / `pan left` / `orbit` / `handheld drift` /
+`crane up` / `rack focus` / `dolly zoom`。
+
+**必须写时长**(`complete within 6 seconds`)——否则模型会设计出 6 秒内做不完的动作。
+
+---
+
+## 三、人物一致性(最容易翻车的地方)
+
+### 原则
+
+**锚点不是「参考」,是「每镜头复述」。**
+
+`appearanceAnchor` 会被 `buildKeyframePrompt()` 拼进每个含人物镜头的 prompt。
+不要指望模型「记得上一张图长什么样」——文生图是无状态的。
+
+### 锚点该写什么
+
+| 必写 | 作用 |
+|---|---|
+| 性别 + 大致年龄 | 最基础的身份 |
+| 发型(长度/颜色/形态) | 识别度最高的特征 |
+| 服装(颜色 + 材质 + 款式,含配饰) | 跨镜头的视觉锚 |
+| 气质/神态 | 决定表演基调 |
+| (可选)标志性道具 | 如「米色帆布托特包」,强化连续性 |
+
+### 好 / 坏对比例子
+
+✅ **好**(具体、可视觉化、无歧义):
+
+```
+A 27-year-old East Asian man with slightly tousled medium-length black hair falling over his forehead,
+thin silver-rimmed glasses, an unbuttoned charcoal-grey wool suit jacket over a wrinkled light-blue
+dress shirt with a loosened dark navy tie, black slacks and scuffed leather shoes, a beige canvas tote
+bag on his shoulder; quiet, gentle, worn-out but warm presence
+```
+
+❌ **坏**(抽象、模型每次理解都不同):
+
+```
+一个疲惫的都市男性    ← 没有发型/服装/年龄,每张图都会换人
+```
+
+### 强化一致性的三个技巧
+
+1. **固定镜头语言**:锚点里带上 `medium shot, eye level`,
+   景别固定后模型更难"重新想象"这个人。
+2. **参考图(image-to-image)**:`generateImage` 支持 `refImage` 参数,
+   传第一张已确认的人物图,能显著提升一致性(需通道支持)。
+3. **风格词锁死**:`styleKeywords` 里写死色调与质感
+   (`sodium orange and cyan teal palette, 35mm film grain`),
+   风格稳定会连带人物观感稳定。
+
+---
+
+## 四、空镜 / B-roll
+
+### 原则
+
+- **不要人物**,或人物仅为远景剪影/背影(`seen from behind as a small distant figure`)。
+- **主体明确**:一个镜头一个视觉焦点,别堆三四个元素。
+- **强调"静物叙事"**:用细节物件代替人,情绪反而更强。
+
+### 实例(本仓实测产出)
+
+```
+Extremely quiet 1AM subway platform still life, a lone glowing vending machine casting cold cyan light
+on empty tiled floor, an abandoned folded newspaper lying on a vacant metal bench seat,
+close-up of smudged fingerprints and dried sweat on a brushed stainless-steel handrail,
+a fluorescent ceiling tube flickering once, distant dark tunnel mouth
+— macro and detail shots, 85mm macro lens and 35mm wide, low eye-level angle
+— high contrast cold cyan practical light with one warm sodium spill
+— City pop neo-noir realism, 35mm film grain, photoreal, 4K
+```
+
+**"still life"(静物)这个词很有用**:它会让模型聚焦在物件与光影上,
+而不是自作主张往画面里塞人。
+
+### 无人物时的负面约束
+
+在意画面干净就加上:
+
+```
+no people, no text, no signage, no watermark
+```
+
+> 实测:不写 `no text` 时,模型会往招牌/海报上写满乱码文字。
+> 城市夜景类镜头尤其明显。
+
+---
+
+## 五、口播稿(产线 B)
+
+### 结构模板
+
+```
+[0-3s]   钩子:提问 / 反常识 / 利益点
+[3-15s]  问题:为什么这事值得听
+[15-70s] 正文:2~4 个要点,每个要点配一个小结句
+[70-85s] 落地:给具体建议(不要空泛)
+[85-90s] CTA:一句行动号召
+```
+
+### 关键约束
+
+```
+【目标时长】约 90 秒(约 324 字,中文口播语速约 3.6 字/秒)
+```
+
+**3.6 字/秒** 是实测的中文 TTS 舒适语速(edge-tts 默认语速下)。
+按这个估算:60s≈216字、90s≈324字、180s≈648字。
+
+### TTS 友好写法
+
+| 写法 | 原因 |
+|---|---|
+| 短句为主(≤ 20 字) | 长句 TTS 会断气、换气点奇怪 |
+| 数字用中文读法 | 「30%」→「百分之三十」;「API」→「接口」 |
+| 少用括号、引号、破折号 | TTS 会念出来或停顿异常 |
+| 避免中英混排 | 英文词读音不可控 |
+| 段落之间留自然停顿 | 分镜按段落切画面更自然 |
+
+---
+
+## 六、通用画质词
+
+直接追加到任何 `imagePrompt` 末尾:
+
+```
+cinematic still frame, high detail, film grain, color graded, 4k
+```
+
+按用途追加:
+
+| 场景 | 追加词 |
+|---|---|
+| 电影感 | `cinematic, anamorphic lens flare, shallow depth of field` |
+| 纪录片感 | `documentary photography, natural light, candid` |
+| 概念图/图表 | `clean minimal infographic style, flat design, no text, white background` |
+| 特写 | `macro shot, extreme detail, soft bokeh` |
+| 竖屏短视频 | (用 `--aspect 9:16`,并在 prompt 加 `vertical composition, subject centered`) |
+
+---
+
+## 七、调优工作流
+
+1. **先 `--dry-run`**:`fmode-mv keyframe --from work/storyboard.json --dry-run`
+   看技能准备发出去的完整 prompt。
+2. **改一处,比一次**:手工编辑 `work/storyboard.json` 里某一镜的 `imagePrompt`,
+   然后 `fmode-mv keyframe --from work/storyboard.json --shot N` 单独重生成。
+3. **有效再推广**:确认这镜的写法好,再把它固化进 `styleKeywords` 或锚点,
+   重跑全部分镜与关键帧。
+4. **别忘记录**:`work/frames/keyframes.json` 存了每张图用的完整 prompt,
+   好的写法从那里捞回来复用。

+ 308 - 0
scripts/assemble.mjs

@@ -0,0 +1,308 @@
+// scripts/assemble.mjs — 剪辑合成成片(本地 ffmpeg,✅ 可用)
+//
+// 职责:
+//   1. 按分镜把 work/clips/ 的片段拼成完整画面轨,可卡节拍对齐
+//   2. 生成字幕 SRT 并烧进画面
+//   3. 混音(音乐 / 人声 / 配音,带淡入淡出)
+//   4. 响度归一化后输出到 out/
+
+import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';
+import { join } from 'node:path';
+import {
+  parseArgs, readJSON, writeJSON, paths, ensureWork, log, humanSize,
+} from '../lib/index.mjs';
+import {
+  checkFfmpeg, run, probe, concat, muxAudio, burnSubtitles, detectBeats, alignToBeats,
+  normalizeLoudness, silence, fileSize,
+} from '../lib/ffmpeg.mjs';
+
+// ==================== 字幕 ====================
+
+/** 秒 → SRT 时间戳 00:00:01,250 */
+export function srtTime(sec) {
+  const s = Math.max(0, Number(sec) || 0);
+  const h = Math.floor(s / 3600);
+  const m = Math.floor((s % 3600) / 60);
+  const ss = Math.floor(s % 60);
+  const ms = Math.round((s - Math.floor(s)) * 1000);
+  const p = (n, w = 2) => String(n).padStart(w, '0');
+  return `${p(h)}:${p(m)}:${p(ss)},${p(ms, 3)}`;
+}
+
+/** 从分镜生成 SRT 内容 */
+export function buildSRT(storyboard, { maxCharsPerLine = 18, minDuration = 0.8 } = {}) {
+  const cues = [];
+  for (const s of storyboard.shots) {
+    const raw = String(s.subtitle || s.narration || s.section || '').trim();
+    if (!raw) continue;
+    // 长句切分为多行、必要时拆成多条字幕
+    const chunks = splitCue(raw, maxCharsPerLine);
+    const per = Math.max(minDuration, s.duration / chunks.length);
+    chunks.forEach((c, i) => {
+      cues.push({
+        start: s.start + i * per,
+        end: Math.min(s.start + s.duration, s.start + (i + 1) * per),
+        text: c,
+      });
+    });
+  }
+  return cues.map((c, i) => `${i + 1}\n${srtTime(c.start)} --> ${srtTime(c.end)}\n${c.text}\n`).join('\n');
+}
+
+function splitCue(text, maxChars) {
+  const clean = text.replace(/\s+/g, ' ').trim();
+  if (clean.length <= maxChars) return [clean];
+  const parts = [];
+  for (const seg of clean.split(/[,。!?;,.!?;]/).filter(Boolean)) {
+    let cur = '';
+    for (const ch of seg) {
+      cur += ch;
+      if (cur.length >= maxChars) {
+        parts.push(cur);
+        cur = '';
+      }
+    }
+    if (cur) parts.push(cur);
+  }
+  return parts.length ? parts : [clean.slice(0, maxChars)];
+}
+
+// ==================== 主流程 ====================
+
+export async function assemble(o = {}) {
+  checkFfmpeg();
+  const {
+    storyboard,
+    clipsDir = paths.clips,
+    outFile = join(paths.out, 'final.mp4'),
+    music, // 主音乐轨(产线A)
+    voice, // 配音轨(产线B)
+    bgm, // 背景乐(产线B 可选)
+    musicVolume = 1.0,
+    voiceVolume = 1.0,
+    bgmVolume = 0.18,
+    burnSub = true,
+    subtitleFile,
+    beatSync = false,
+    fadeOut = 2.0,
+    aspect,
+    fps = 30,
+  } = o;
+
+  if (!existsSync(clipsDir)) throw new Error(`片段目录不存在:${clipsDir}。先跑 fmode-mv video`);
+  mkdirSync(paths.out, { recursive: true });
+
+  // ---------- 1. 收集片段 ----------
+  const want = new Set((storyboard?.shots || []).map((s) => `shot-${String(s.id).padStart(2, '0')}`));
+  let clips = readdirSync(clipsDir)
+    .filter((f) => f.endsWith('.mp4'))
+    .filter((f) => !want.size || want.has(f.replace(/\.(mp4|fallback\.mp4)$/, '').replace(/\.fallback$/, '')))
+    .sort();
+
+  // 排序:优先 shot-XX.mp4,缺失时用 shot-XX.fallback.mp4 顶上
+  const byShot = new Map();
+  for (const f of clips) {
+    const m = f.match(/^shot-(\d+)(\.fallback)?\.mp4$/);
+    if (!m) continue;
+    const id = Number(m[1]);
+    const isFallback = !!m[2];
+    const cur = byShot.get(id);
+    if (!cur || (cur.isFallback && !isFallback)) byShot.set(id, { file: join(clipsDir, f), isFallback });
+  }
+  let ordered = [...byShot.entries()].sort((a, b) => a[0] - b[0]).map(([id, v]) => ({ id, ...v }));
+
+  if (!ordered.length) throw new Error(`在 ${clipsDir} 没找到 shot-*.mp4 片段。先跑 fmode-mv video`);
+
+  log.step(`合成成片(${ordered.length} 段)`);
+  const fallbackCount = ordered.filter((c) => c.isFallback).length;
+  if (fallbackCount) log.warn(`${fallbackCount}/${ordered.length} 段来自 Ken Burns 兜底(非真视频生成)`);
+
+  // ---------- 2. 卡点对齐(可选) ----------
+  let beatInfo = null;
+  if (beatSync && music && existsSync(music)) {
+    log.info('检测节拍(能量包络法)…');
+    beatInfo = await detectBeats(music);
+    if (!beatInfo.beats.length) {
+      log.warn('未检出节拍点(纯音/弱起拍素材常见),跳过卡点对齐,按分镜原时长拼接');
+      log.dim('提示:卡点需要有明显鼓点/瞬态的音频素材(如真实编曲成品)');
+    } else {
+      log.ok(`BPM ≈ ${beatInfo.bpm || '未知'},检出 ${beatInfo.beats.length} 个节拍点`);
+      if (storyboard?.shots) {
+        storyboard.shots = alignToBeats(beatInfo, storyboard.shots);
+        log.ok('分镜时长已吸附到节拍点');
+      }
+    }
+  }
+
+  // ---------- 3. 拼接画面 ----------
+  const [w, h] = sizeOf(aspect || storyboard?.aspect || '16:9');
+  const videoOnly = join(paths.work, 'video-only.mp4');
+  await concat(ordered.map((c) => c.file), videoOnly, { width: w, height: h, fps });
+  const vInfo = await probe(videoOnly);
+  log.ok(`画面轨:${videoOnly}(${vInfo.duration.toFixed(1)}s,${vInfo.width}x${vInfo.height},${humanSize(fileSize(videoOnly))})`);
+
+  // ---------- 4. 字幕 ----------
+  let srtPath = null;
+  if (burnSub && storyboard?.shots?.length) {
+    const content = buildSRT(storyboard);
+    if (content.trim()) {
+      srtPath = subtitleFile || join(paths.work, 'subtitles.srt');
+      writeFileSync(srtPath, content, 'utf8');
+      log.ok(`字幕:${srtPath}(${content.split('\n\n').filter(Boolean).length} 条)`);
+    } else {
+      log.warn('分镜没有 subtitle/narration 文本,跳过字幕');
+    }
+  }
+
+  // ---------- 5. 混音 ----------
+  let stage = videoOnly;
+  const audioTracks = [];
+  if (music && existsSync(music)) audioTracks.push({ file: music, volume: musicVolume, label: '音乐' });
+  if (voice && existsSync(voice)) audioTracks.push({ file: voice, volume: voiceVolume, label: '配音' });
+  if (bgm && existsSync(bgm)) audioTracks.push({ file: bgm, volume: bgmVolume, label: '背景乐' });
+
+  let mixed = null;
+  if (audioTracks.length === 1) {
+    const t = audioTracks[0];
+    mixed = join(paths.work, 'audio-mixed.m4a');
+    await run(
+      ['-y', '-i', t.file, '-af', `volume=${t.volume}`, '-c:a', 'aac', '-b:a', '192k', mixed],
+      { label: 'volume' }
+    );
+    log.ok(`音轨:仅 ${t.label}(音量 ${t.volume})`);
+  } else if (audioTracks.length > 1) {
+    mixed = join(paths.work, 'audio-mixed.m4a');
+    const args = ['-y'];
+    for (const t of audioTracks) args.push('-i', t.file);
+    const fc =
+      audioTracks.map((t, i) => `[${i}:a]volume=${t.volume}[a${i}]`).join(';') +
+      ';' +
+      audioTracks.map((_, i) => `[a${i}]`).join('') +
+      `amix=inputs=${audioTracks.length}:duration=longest:normalize=0[aout]`;
+    await run([...args, '-filter_complex', fc, '-map', '[aout]', '-c:a', 'aac', '-b:a', '192k', mixed], { label: 'amix' });
+    log.ok(`音轨:${audioTracks.map((t) => `${t.label}(${t.volume})`).join(' + ')}`);
+  } else {
+    log.warn('未提供任何音轨(--music / --voice / --bgm),成片将无声音');
+  }
+
+  // ---------- 6. 合成 + 字幕 ----------
+  let merged = mixed ? join(paths.work, 'merged.mp4') : stage;
+  if (mixed) {
+    await muxAudio(videoOnly, mixed, merged, { duration: vInfo.duration, fadeOut });
+  }
+  if (srtPath) {
+    const burned = join(paths.work, 'subtitled.mp4');
+    try {
+      await burnSubtitles(merged, srtPath, burned, { fontSize: Math.round(h / 36), marginV: Math.round(h / 26) });
+      merged = burned;
+      log.ok('字幕已烧入画面');
+    } catch (e) {
+      log.warn(`字幕烧入失败(成片不带字幕,SRT 仍在 ${srtPath}):${e.message.split('\n')[0]}`);
+    }
+  }
+
+  // ---------- 7. 响度归一化 ----------
+  let finalOut = outFile;
+  try {
+    await normalizeLoudness(merged, outFile);
+    log.ok(`响度归一化完成(I=-14 LUFS)`);
+  } catch (e) {
+    log.warn(`响度归一化失败,直接输出:${e.message.split('\n')[0]}`);
+    await run(['-y', '-i', merged, '-c', 'copy', outFile], { label: 'copy(final)' });
+  }
+
+  const fInfo = await probe(finalOut);
+  const bytes = fileSize(finalOut);
+
+  writeJSON(join(paths.out, 'manifest.json'), {
+    outFile: finalOut,
+    duration: fInfo.duration,
+    size: bytes,
+    resolution: `${fInfo.width}x${fInfo.height}`,
+    clips: ordered.map((c) => ({ id: c.id, file: c.file, fallback: c.isFallback })),
+    fallbackCount,
+    beatSync: beatInfo && beatInfo.beats.length ? { bpm: beatInfo.bpm, beats: beatInfo.beats.length, applied: true } : null,
+    subtitles: srtPath,
+    audioTracks: audioTracks.map((t) => ({ label: t.label, volume: t.volume })),
+    _meta: { createdBy: 'fmode-mv/assemble', generatedAt: new Date().toISOString() },
+  });
+
+  log.step('成片');
+  log.key('文件', finalOut);
+  log.key('时长', `${fInfo.duration.toFixed(1)}s`);
+  log.key('分辨率', `${fInfo.width}x${fInfo.height} @ ${fInfo.fps || fps}fps`);
+  log.key('大小', humanSize(bytes));
+  if (fallbackCount) log.warn(`提示:${fallbackCount} 段为兜底运镜(非真 AI 图生视频)`);
+  return { outFile: finalOut, info: fInfo, fallbackCount };
+}
+
+function sizeOf(aspect) {
+  return aspect === '9:16' ? [720, 1280] : aspect === '1:1' ? [960, 960] : [1280, 720];
+}
+
+export async function main(argv = process.argv.slice(2)) {
+  const { _, flags } = parseArgs(argv);
+
+  if (flags.help) {
+    console.log(`
+fmode-mv assemble — 剪辑合成成片(本地 ffmpeg,✅ 可用)
+
+用法:
+  # 产线A:MV(音乐为主音轨,卡点对齐)
+  fmode-mv assemble --from work/storyboard.json --music work/audio/music.mp3 --beat-sync
+
+  # 产线B:口播(配音为主音轨 + 轻背景乐)
+  fmode-mv assemble --from work/storyboard.json --voice work/audio/voiceover.mp3 --bgm work/audio/bgm.mp3
+
+选项:
+  --from <file>       分镜 JSON(取字幕与时长)
+  --clips <dir>       片段目录(默认 work/clips)
+  --music <file>      主音乐轨
+  --voice <file>      配音轨
+  --bgm <file>        背景乐轨(自动压到 0.18 音量)
+  --music-volume <n>  音乐音量(默认 1.0)
+  --voice-volume <n>  配音音量(默认 1.0)
+  --bgm-volume <n>    背景乐音量(默认 0.18)
+  --beat-sync         按音乐节拍对齐镜头(能量包络节拍检测)
+  --no-subtitle       不生成/不烧入字幕
+  --srt <file>        使用现成 SRT
+  --out <file>        输出(默认 out/final.mp4)
+  --fade-out <sec>    片尾音频淡出(默认 2.0)
+
+产出:
+  out/final.mp4 + out/manifest.json(含兜底片段数、BPM、音轨信息)
+`);
+    return 0;
+  }
+
+  ensureWork();
+  const sb = flags.from ? readJSON(String(flags.from)) : null;
+  if (flags.from && !sb) throw new Error(`分镜文件不存在:${flags.from}`);
+
+  const r = await assemble({
+    storyboard: sb,
+    clipsDir: String(flags.clips || paths.clips),
+    outFile: String(flags.out || join(paths.out, 'final.mp4')),
+    music: typeof flags.music === 'string' ? flags.music : undefined,
+    voice: typeof flags.voice === 'string' ? flags.voice : undefined,
+    bgm: typeof flags.bgm === 'string' ? flags.bgm : undefined,
+    musicVolume: Number(flags['music-volume'] ?? 1.0),
+    voiceVolume: Number(flags['voice-volume'] ?? 1.0),
+    bgmVolume: Number(flags['bgm-volume'] ?? 0.18),
+    burnSub: !flags['no-subtitle'],
+    subtitleFile: typeof flags.srt === 'string' ? flags.srt : undefined,
+    beatSync: !!flags['beat-sync'],
+    fadeOut: Number(flags['fade-out'] ?? 2.0),
+    aspect: typeof flags.aspect === 'string' ? flags.aspect : undefined,
+  });
+  return 0;
+}
+
+if (import.meta.url === `file://${process.argv[1]}`) {
+  main().then((c) => process.exit(Number(c) || 0)).catch((e) => {
+    console.error(`✗ ${e.message}`);
+    process.exit(1);
+  });
+}
+
+export { silence, fileSize };

+ 177 - 0
scripts/keyframe.mjs

@@ -0,0 +1,177 @@
+// scripts/keyframe.mjs — 场景 & 人物关键帧(provider: image)
+//
+// ✅ 通道可用(实测 gpt-image-2.5-sunburst 返回 b64_json)。
+// 关键点:人物一致性 —— 每个含人物的镜头都必须复述 appearanceAnchor。
+
+import { existsSync, mkdirSync, writeFileSync, readdirSync, statSync } from 'node:fs';
+import { join } from 'node:path';
+import {
+  parseArgs, readJSON, writeJSON, paths, ensureWork, log, invoke, humanSize,
+} from '../lib/index.mjs';
+import { buildKeyframePrompt, aspectToSize, DEFAULT_ANCHOR } from '../lib/prompts.mjs';
+
+/**
+ * 生成关键帧。
+ * @param {object} o
+ * @param {object} o.storyboard  分镜(可选)
+ * @param {string} [o.prompt]    裸 prompt(无分镜时)
+ * @param {number} [o.shotId]    只生成某一镜
+ * @param {number} [o.limit]     最多生成几镜
+ * @param {string} [o.outDir]
+ */
+export async function generateKeyframes(o = {}) {
+  const {
+    storyboard,
+    prompt,
+    shotId,
+    limit,
+    outDir = paths.frames,
+    aspect = storyboard?.aspect || '16:9',
+    tier = 'standard',
+    style = '',
+    model,
+    anchorOverride,
+    dryRun = false,
+  } = o;
+
+  mkdirSync(outDir, { recursive: true });
+  const anchor = anchorOverride || storyboard?.appearanceAnchor || DEFAULT_ANCHOR.song;
+  const styleKw = style || storyboard?.styleKeywords || '';
+  const size = aspectToSize(aspect, tier);
+
+  let jobs;
+  if (storyboard) {
+    jobs = storyboard.shots.filter((s) => !shotId || s.id === Number(shotId));
+    if (limit) jobs = jobs.slice(0, Number(limit));
+  } else {
+    if (!prompt) throw new Error('需要 --prompt 或 --from <storyboard.json>');
+    jobs = [{ id: 0, imagePrompt: prompt, description: prompt, type: 'broll', hasCharacter: false }];
+  }
+
+  const results = [];
+  for (const shot of jobs) {
+    const fullPrompt = buildKeyframePrompt(shot, { appearanceAnchor: anchor, style: styleKw, aspect });
+    const name = shot.id ? `shot-${String(shot.id).padStart(2, '0')}` : 'keyframe';
+    const file = join(outDir, `${name}.png`);
+
+    log.info(`[${name}] ${String(shot.description || '').slice(0, 60)}`);
+    if (dryRun) {
+      log.dim(`prompt: ${fullPrompt.slice(0, 160)}…`);
+      results.push({ id: shot.id, file: null, status: 'pending', prompt: fullPrompt, size });
+      continue;
+    }
+
+    try {
+      const res = await invoke('image', { prompt: fullPrompt, size, n: 1, model });
+      const buf = res.buffers[0];
+      writeFileSync(file, buf);
+      log.ok(`→ ${file}(${humanSize(buf.length)},${size})`);
+      results.push({
+        id: shot.id,
+        file,
+        bytes: buf.length,
+        size,
+        model: res.model,
+        prompt: fullPrompt,
+        status: 'ok',
+      });
+    } catch (e) {
+      log.err(`[${name}] 生成失败:${e.message.split('\n')[0]}`);
+      results.push({ id: shot.id, file: null, status: 'failed', error: e.message, prompt: fullPrompt });
+      if (jobs.length === 1) throw e;
+    }
+  }
+
+  const okCount = results.filter((r) => r.status === 'ok').length;
+  const failCount = results.filter((r) => r.status === 'failed').length;
+  writeJSON(join(outDir, 'keyframes.json'), {
+    count: results.length,
+    ok: okCount,
+    failed: failCount,
+    pending: results.filter((r) => r.status === 'pending').length,
+    aspect,
+    size,
+    appearanceAnchor: anchor,
+    styleKeywords: styleKw,
+    frames: results,
+    _meta: { createdBy: 'fmode-mv/keyframe', generatedAt: new Date().toISOString() },
+  });
+
+  return { results, okCount, failCount, outDir };
+}
+
+export async function main(argv = process.argv.slice(2)) {
+  const { _, flags } = parseArgs(argv);
+
+  if (flags.help || (!flags.from && !flags.prompt)) {
+    console.log(`
+fmode-mv keyframe — 场景 & 人物关键帧(image 通道,✅ 可用)
+
+用法:
+  fmode-mv keyframe --from work/storyboard.json            # 按分镜批量生成
+  fmode-mv keyframe --from work/storyboard.json --shot 1   # 只生成第 1 镜
+  fmode-mv keyframe --from work/storyboard.json --limit 4  # 只生成前 4 镜
+  fmode-mv keyframe --prompt "傍晚城市天台,主角侧影,霓虹初上" --name rooftop
+
+选项:
+  --from <file>      分镜 JSON
+  --shot <id>        只生成指定镜头
+  --limit <n>        最多生成 n 镜
+  --prompt "..."     裸 prompt(不依赖分镜)
+  --out <dir>        输出目录(默认 work/frames)
+  --aspect <r>       16:9 | 9:16 | 1:1(默认取分镜)
+  --tier standard|large   尺寸档位(默认 standard)
+  --style "..."      追加风格关键词(英文更稳)
+  --anchor "..."     覆盖人物外观锚点(英文)
+  --model <name>     覆盖图像模型(默认 gpt-image-2.5-sunburst)
+  --dry-run          只打印将发送的 prompt,不实际生成(不产生费用)
+
+产出:
+  work/frames/shot-XX.png + work/frames/keyframes.json
+`);
+    return 0;
+  }
+
+  ensureWork();
+  let storyboard = null;
+  if (flags.from) {
+    const from = String(flags.from);
+    if (!existsSync(from)) throw new Error(`分镜文件不存在:${from}`);
+    storyboard = readJSON(from);
+    log.step(`生成关键帧:${storyboard.title || ''}(共 ${storyboard.shots.length} 镜)`);
+  } else {
+    log.step('生成关键帧(单张)');
+  }
+
+  const r = await generateKeyframes({
+    storyboard,
+    prompt: flags.prompt ? String(flags.prompt) : undefined,
+    shotId: flags.shot ? Number(flags.shot) : undefined,
+    limit: flags.limit ? Number(flags.limit) : undefined,
+    outDir: typeof flags.out === 'string' ? flags.out : paths.frames,
+    aspect: flags.aspect,
+    tier: flags.tier || 'standard',
+    style: flags.style,
+    model: flags.model,
+    anchorOverride: flags.anchor,
+    dryRun: !!flags['dry-run'],
+  });
+
+  if (flags['dry-run']) {
+    log.warn(`dry-run:${r.results.length} 个 prompt 已生成,未调用图像通道(无费用)`);
+  } else {
+    log.ok(`完成:成功 ${r.okCount} / 失败 ${r.failCount}`);
+    const files = readdirSync(paths.frames).filter((f) => f.endsWith('.png'));
+    let total = 0;
+    for (const f of files) total += statSync(join(paths.frames, f)).size;
+    log.key('目录', `${paths.frames}(${files.length} 张,共 ${humanSize(total)})`);
+  }
+  return r.failCount > 0 && r.okCount === 0 ? 1 : 0;
+}
+
+if (import.meta.url === `file://${process.argv[1]}`) {
+  main().then((c) => process.exit(Number(c) || 0)).catch((e) => {
+    console.error(`✗ ${e.message}`);
+    process.exit(1);
+  });
+}

+ 125 - 0
scripts/lipsync.mjs

@@ -0,0 +1,125 @@
+// scripts/lipsync.mjs — 唇形驱动(provider: lipsync)
+//
+// ⚠️ 现状:平台暂无唇形驱动通道。
+// 本脚本:
+//   · probe() 真实探活当前端点,明确告知是否可用;
+//   · 不可用时抛出 ProviderUnavailableError,并给出三条可操作替代路线;
+//   · 若用户自建服务(SadTalker / Wav2Lip / LatentSync),用 FEME_LIPSYNC_ENDPOINT 指过来即可跑通。
+
+import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
+import { join } from 'node:path';
+import {
+  parseArgs, readJSON, writeJSON, paths, ensureWork, log, invoke, download,
+} from '../lib/index.mjs';
+import { getProvider } from '../lib/providers.mjs';
+
+function pickUrl(result, re = /\.(mp4|mov|webm)/i) {
+  const urls = [];
+  const walk = (o, d = 0) => {
+    if (!o || d > 6) return;
+    if (typeof o === 'string' && /^https?:\/\//.test(o) && re.test(o)) urls.push(o);
+    else if (Array.isArray(o)) o.forEach((x) => walk(x, d + 1));
+    else if (typeof o === 'object') Object.values(o).forEach((v) => walk(v, d + 1));
+  };
+  walk(result);
+  return urls[0] || null;
+}
+
+export async function driveLip(shot, { faceImage, audio, outDir, model } = {}) {
+  const p = getProvider('lipsync');
+  const dir = outDir || paths.lipsync;
+  mkdirSync(dir, { recursive: true });
+
+  const result = await p.call({ image: faceImage, audio, model: model || p.defaultModel() });
+  const url = pickUrl(result.result ?? result);
+  if (!url) throw new Error(`唇形通道返回成功但未包含视频 URL:${JSON.stringify(result).slice(0, 300)}`);
+  const buf = await download(url);
+  const name = shot?.id ? `lip-${String(shot.id).padStart(2, '0')}.mp4` : 'lipsync.mp4';
+  const out = join(dir, name);
+  writeFileSync(out, buf);
+  return { path: out, bytes: buf.length, url, raw: result };
+}
+
+export async function main(argv = process.argv.slice(2)) {
+  const { _, flags } = parseArgs(argv);
+  const face = flags.face || _[0];
+  const audio = flags.audio || _[1];
+
+  if (flags.help || (!face && !flags.probe)) {
+    console.log(`
+fmode-mv lipsync — 唇形驱动(lipsync 通道)
+
+用法:
+  fmode-mv lipsync --probe                                  # 探活当前通道
+  fmode-mv lipsync --face work/frames/shot-01.png --audio work/audio/vocals.wav --shot 1
+  fmode-mv lipsync --from work/storyboard.json --audio work/audio/vocals.wav   # 批量
+
+选项:
+  --probe            只探活,不生成
+  --face <png>       人脸关键帧
+  --audio <wav>      驱动音频(人声干声)
+  --from <file>      分镜(批量处理 lipSync:true 的镜头)
+  --shot <id>        只处理某镜
+  --out <dir>        输出目录(默认 work/lipsync)
+  --model <name>     覆盖模型
+
+⚠️ 现状:平台暂无唇形驱动通道。替代方案:
+  A) 自建 SadTalker / Wav2Lip / LatentSync,然后:
+     export FEME_LIPSYNC_ENDPOINT=https://your-host/lipsync
+     本命令即可直接跑通(请求体:{image, audio, model})
+  B) 不做唇形:人物镜头改用「表情/头部微动」的图生视频镜头,观感可接受
+  C) 纯空镜 B-roll + 字幕(纯音乐 MV 常见做法,零风险)
+`);
+    return 0;
+  }
+
+  const p = getProvider('lipsync');
+  log.step('唇形通道探活');
+  const pr = await p.probe({});
+  log.key('状态', pr.available ? '可用' : '不可用');
+  log.info(pr.detail);
+  if (flags.probe) return pr.available ? 0 : 3;
+
+  if (!pr.available) {
+    throw Object.assign(new Error(`唇形驱动通道不可用:${pr.detail}`), { hint: p.hint, name: 'ProviderUnavailableError' });
+  }
+
+  ensureWork();
+  if (flags.from) {
+    const sb = readJSON(String(flags.from));
+    const jobs = sb.shots.filter((s) => s.lipSync && (!flags.shot || s.id === Number(flags.shot)));
+    if (!jobs.length) {
+      log.warn('分镜里没有标记 lipSync:true 的镜头,无需处理');
+      return 0;
+    }
+    for (const shot of jobs) {
+      const faceImg = join(paths.frames, `shot-${String(shot.id).padStart(2, '0')}.png`);
+      if (!existsSync(faceImg)) {
+        log.err(`[shot ${shot.id}] 缺少人脸关键帧 ${faceImg}`);
+        continue;
+      }
+      const r = await driveLip(shot, { faceImage: faceImg, audio: flags.audio });
+      log.ok(`[shot ${shot.id}] → ${r.path}`);
+    }
+    return 0;
+  }
+
+  if (!face || !audio) throw new Error('需要 --face <png> 与 --audio <wav>');
+  if (!existsSync(face)) throw new Error(`人脸图片不存在:${face}`);
+  if (!existsSync(audio)) throw new Error(`音频不存在:${audio}`);
+  const r = await driveLip({ id: flags.shot ? Number(flags.shot) : null }, { faceImage: face, audio });
+  log.ok(`→ ${r.path}`);
+  return 0;
+}
+
+if (import.meta.url === `file://${process.argv[1]}`) {
+  main().then((c) => process.exit(Number(c) || 0)).catch(async (e) => {
+    const { reportUnavailable, log: L } = await import('../lib/index.mjs');
+    if (e?.name === 'ProviderUnavailableError' || e?.hint) {
+      reportUnavailable('lipsync', e);
+      process.exit(3);
+    }
+    L.err(e.message);
+    process.exit(1);
+  });
+}

+ 132 - 0
scripts/lyrics.mjs

@@ -0,0 +1,132 @@
+// scripts/lyrics.mjs — 作曲填词
+//
+// 主题 → 歌词 + 曲风 prompt + 结构标记(intro/verse/chorus/bridge/outro)
+// 走 chat 通道(实测可用)。产物落 work/song.json。
+
+import {
+  parseArgs, writeJSON, readJSON, paths, ensureWork, log, chatJSON,
+} from '../lib/index.mjs';
+import { buildLyricsPrompt } from '../lib/prompts.mjs';
+
+const SYSTEM = `你是资深华语流行音乐制作人兼作词人,为 AI 音乐生成模型(Suno 类)撰写可直接投喂的素材。
+硬性要求:
+1. 歌词必须自带结构标记,每段用方括号起头,例如 [Intro] [Verse 1] [Pre-Chorus] [Chorus] [Bridge] [Outro]。
+2. Chorus 要朗朗上口、有记忆点,副歌句尾押韵(中文押韵即可,不必强求工整)。
+3. 只输出 JSON,不要任何解释文字、不要 markdown 代码围栏。
+4. stylePrompt 字段用英文逗号短语描述曲风/编曲/情绪,供音乐模型直接使用(例:"dreamy city pop, 88 bpm, warm analog synths, female vocal, lush reverb")。
+5. 歌词内容必须为原创,不得抄袭任何已有歌曲的句子。`;
+
+const SCHEMA_HINT = `严格输出如下 JSON(字段名不可改):
+{
+  "title": "歌名(4-10字,中文)",
+  "genre": "曲风(中文,如:城市流行 City Pop)",
+  "mood": "情绪关键词(中文,2-4个词)",
+  "bpm": 92,
+  "language": "zh",
+  "structure": ["Intro","Verse 1","Pre-Chorus","Chorus","Verse 2","Chorus","Bridge","Chorus","Outro"],
+  "lyrics": "[Intro]\\n(器乐)\\n\\n[Verse 1]\\n第一段歌词...\\n\\n[Chorus]\\n副歌...",
+  "stylePrompt": "英文风格提示词,逗号分隔",
+  "summary": "一句话说明这首歌讲什么(中文)"
+}`;
+
+export async function buildLyrics(theme, opts = {}) {
+  const { genre = '', mood = '', style = '', language = '中文', extra = '', model } = opts;
+  const userPrompt = buildLyricsPrompt({ theme, genre, mood, style, language, extra }) + '\n\n' + SCHEMA_HINT;
+
+  const res = await chatJSON({
+    model,
+    system: SYSTEM,
+    messages: [{ role: 'user', content: userPrompt }],
+    temperature: 0.9,
+    max_tokens: 4096,
+  });
+  const parsed = res.json;
+  return {
+    ...parsed,
+    _meta: {
+      model: res.model,
+      createdBy: 'fmode-mv/lyrics',
+      theme,
+      maxTokensUsed: res.maxTokensUsed,
+      truncated: !!res.repaired,
+    },
+  };
+}
+
+export async function main(argv = process.argv.slice(2)) {
+  const { _, flags } = parseArgs(argv);
+  const theme = _.join(' ').trim() || flags.theme;
+
+  if (!theme || flags.help) {
+    console.log(`
+fmode-mv lyrics — 作曲填词(chat 通道)
+
+用法:
+  fmode-mv lyrics "主题描述" [选项]
+
+选项:
+  --genre "曲风"       如 "city pop" / "国风民谣" / "电子舞曲"
+  --mood "情绪"        如 "温暖治愈" / "孤独疏离"
+  --style "补充要求"   如 "女声,钢琴为主"
+  --lang zh|en         歌词语言(默认 zh)
+  --model <name>       覆盖 LLM 模型(默认 deepseek-v4-flash)
+  --out <file>         输出文件(默认 work/song.json)
+  --json               只打印 JSON
+
+示例:
+  fmode-mv lyrics "深夜加班后独自回家的城市白领" --genre "city pop" --mood "孤独但温暖"
+`);
+    return 0;
+  }
+
+  ensureWork();
+  log.step(`作曲填词:${theme}`);
+  if (flags.genre) log.key('曲风', flags.genre);
+  if (flags.mood) log.key('情绪', flags.mood);
+
+  const song = await buildLyrics(theme, {
+    genre: flags.genre,
+    mood: flags.mood,
+    style: flags.style,
+    language: flags.lang === 'en' ? 'English' : '中文',
+    model: flags.model,
+  });
+
+  const outFile = typeof flags.out === 'string' ? flags.out : paths.song;
+  writeJSON(outFile, song);
+
+  if (flags.json) {
+    console.log(JSON.stringify(song, null, 2));
+    return 0;
+  }
+
+  log.ok(`歌名:《${song.title}》`);
+  log.key('曲风', `${song.genre || '-'} / ${song.bpm || '?'} BPM / ${song.mood || '-'}`);
+  log.key('结构', (song.structure || []).join(' → '));
+  log.key('风格 prompt', song.stylePrompt || '-');
+  console.log('');
+  console.log(song.lyrics || '(空)');
+  console.log('');
+  log.ok(`已写入 ${outFile}`);
+
+  // 自检:歌词结构标记 & 副歌存在
+  const lyr = String(song.lyrics || '');
+  const issues = [];
+  if (!/\[Verse|\[verse|\[主歌/.test(lyr)) issues.push('缺少 Verse 段落标记');
+  if (!/\[Chorus|\[chorus|\[副歌/.test(lyr)) issues.push('缺少 Chorus 段落标记');
+  if (lyr.replace(/\[[^\]]*\]/g, '').trim().length < 60) issues.push('歌词正文过短');
+  if (!song.stylePrompt) issues.push('缺少 stylePrompt(音乐模型需要)');
+  if (issues.length) {
+    log.warn(`歌词自检提示:${issues.join(';')}`);
+  } else {
+    log.ok('歌词自检通过(含主歌/副歌结构标记 + 风格 prompt)');
+  }
+  return 0;
+}
+
+if (import.meta.url === `file://${process.argv[1]}`) {
+  main().then((c) => process.exit(Number(c) || 0)).catch((e) => {
+    console.error(`✗ ${e.message}`);
+    process.exit(1);
+  });
+}

+ 147 - 0
scripts/music.mjs

@@ -0,0 +1,147 @@
+// scripts/music.mjs — AI 音乐生成(provider: music)
+//
+// ⚠️ 现状:平台 Suno 渠道未开通(实测 POST /suno/submit/music 返回 503 "Task request failed")。
+//    本脚本依然会「真试一次」——若平台后续开通,无需改代码即可跑通;
+//    失败则抛出带指引的 ProviderUnavailableError,绝不伪造音频文件。
+
+import { existsSync, writeFileSync } from 'node:fs';
+import { join } from 'node:path';
+import {
+  parseArgs, readJSON, writeJSON, paths, ensureWork, log, invoke, download, paths as P,
+} from '../lib/index.mjs';
+import { getProvider } from '../lib/providers.mjs';
+import { run } from '../lib/ffmpeg.mjs';
+
+/** 从 provider 返回结果里尽力提取音频 URL */
+function extractAudioUrls(result) {
+  const urls = [];
+  const walk = (o, depth = 0) => {
+    if (!o || depth > 5) return;
+    if (typeof o === 'string' && /^https?:\/\/.+\.(mp3|wav|m4a|flac)(\?.*)?$/i.test(o)) urls.push(o);
+    else if (Array.isArray(o)) o.forEach((x) => walk(x, depth + 1));
+    else if (typeof o === 'object') {
+      for (const [k, v] of Object.entries(o)) {
+        if (/audio_url|stream_audio_url|source_audio_url|url|audio/i.test(k) && typeof v === 'string' && /^https?:/.test(v)) urls.push(v);
+        else walk(v, depth + 1);
+      }
+    }
+  };
+  walk(result);
+  return [...new Set(urls)];
+}
+
+export async function generateMusic({ prompt, lyrics, instrumental = false, title, outDir }) {
+  const p = getProvider('music');
+  ensureWork();
+  const dir = outDir || P.audio;
+  const { mkdirSync } = await import('node:fs');
+  mkdirSync(dir, { recursive: true });
+
+  log.info(`提交音乐生成(模型 ${p.defaultModel()})…`);
+  const { taskId, result } = await p.call({ prompt, lyrics, instrumental, title });
+  log.ok(`任务完成 taskId=${taskId}`);
+
+  const urls = extractAudioUrls(result);
+  if (!urls.length) {
+    throw new Error(
+      `音乐通道返回成功但未包含音频 URL。原始返回:\n${JSON.stringify(result).slice(0, 600)}`
+    );
+  }
+
+  const files = [];
+  for (let i = 0; i < urls.length; i++) {
+    const buf = await download(urls[i]);
+    const f = join(dir, urls.length > 1 ? `music-${i + 1}.mp3` : 'music.mp3');
+    writeFileSync(f, buf);
+    files.push({ path: f, url: urls[i], bytes: buf.length });
+    log.ok(`已下载 ${f}(${Math.round(buf.length / 1024)}KB)`);
+  }
+
+  writeJSON(join(dir, 'music.json'), {
+    taskId,
+    title: title || '',
+    model: p.defaultModel(),
+    prompt,
+    instrumental,
+    files: files.map((f) => ({ path: f.path, bytes: f.bytes })),
+    raw: result,
+    _meta: { createdBy: 'fmode-mv/music', generatedAt: new Date().toISOString() },
+  });
+  return { taskId, files, raw: result };
+}
+
+export async function main(argv = process.argv.slice(2)) {
+  const { _, flags } = parseArgs(argv);
+
+  if (flags.help || (!_.length && !flags.from)) {
+    console.log(`
+fmode-mv music — AI 音乐生成(music 通道)
+
+用法:
+  fmode-mv music --from work/song.json            # 用作曲填词的产物直接生成
+  fmode-mv music "dreamy city pop, 92bpm, female vocal" --title "夜行"
+  fmode-mv music --from work/song.json --instrumental   # 只要伴奏
+
+选项:
+  --from <file>     歌曲 JSON(读取 stylePrompt / lyrics / title)
+  --title "歌名"
+  --instrumental    纯伴奏,不生成人声
+  --model <name>    音乐模型版本(默认 chirp-v3-5)
+  --out <dir>       输出目录(默认 work/audio)
+
+⚠️ 现状:平台 Suno 渠道未开通。本命令会真实尝试一次;
+   若失败会明确告知如何切换通道或对接外部服务(不会伪造产物)。
+   用 fmode-mv providers 查看通道状态。
+`);
+    return 0;
+  }
+
+  ensureWork();
+  let prompt = _.join(' ').trim();
+  let lyrics = null;
+  let title = flags.title ? String(flags.title) : '';
+
+  if (flags.from) {
+    const song = readJSON(String(flags.from));
+    if (!song) throw new Error(`未找到文件:${flags.from}`);
+    prompt = prompt || song.stylePrompt || song.genre || '';
+    lyrics = song.lyrics || null;
+    title = title || song.title || '';
+    log.step(`AI 音乐生成:《${title}》`);
+    log.key('风格', prompt.slice(0, 80) + (prompt.length > 80 ? '…' : ''));
+    log.key('歌词', `${String(lyrics || '').length} 字`);
+  } else {
+    log.step('AI 音乐生成');
+    log.key('风格', prompt);
+  }
+  if (!prompt) throw new Error('缺少风格 prompt。用 --from work/song.json 或直接给风格描述。');
+
+  const provider = getProvider('music');
+  const probe = await provider.probe({ deep: false });
+  if (probe.available === false) {
+    log.warn(`通道探活:${probe.detail}`);
+  }
+
+  const res = await generateMusic({
+    prompt,
+    lyrics,
+    instrumental: !!flags.instrumental,
+    title,
+    outDir: typeof flags.out === 'string' ? flags.out : undefined,
+  });
+
+  log.key('产物', res.files.map((f) => f.path).join(', '));
+  return 0;
+}
+
+if (import.meta.url === `file://${process.argv[1]}`) {
+  main().then((c) => process.exit(Number(c) || 0)).catch(async (e) => {
+    const { reportUnavailable, log: L } = await import('../lib/index.mjs');
+    if (e?.name === 'ProviderUnavailableError') {
+      reportUnavailable('music', e);
+      process.exit(3);
+    }
+    L.err(e.message);
+    process.exit(1);
+  });
+}

+ 295 - 0
scripts/pipeline-a.mjs

@@ -0,0 +1,295 @@
+// scripts/pipeline-a.mjs — 产线A 编排:AI 单曲 MV 端到端
+//
+// 主题 → 作曲填词 → AI 音乐 → 人声/伴奏分离 → 关键帧 → 图生视频 → 唇形 → 剪辑成片
+//
+// 编排原则:
+//   · 每一步都能单独跑(对应 fmode-mv <step>),pipeline 只是串起来;
+//   · 通道不可用的步骤「如实跳过并记录原因」,能兜底的就兜底(视频→Ken Burns);
+//   · 最后打印一张「步骤执行表」,一眼看清哪些是真产物、哪些是兜底/跳过。
+
+import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
+import { join } from 'node:path';
+import {
+  parseArgs, readJSON, writeJSON, paths, ensureWork, log, humanSize,
+} from '../lib/index.mjs';
+import { buildLyrics } from './lyrics.mjs';
+import { buildStoryboardFromSong, renderStoryboardMd } from './storyboard.mjs';
+import { generateKeyframes } from './keyframe.mjs';
+import { generateClips } from './video.mjs';
+import { assemble } from './assemble.mjs';
+import { getProvider } from '../lib/providers.mjs';
+import { probe as ffprobe, listMedia } from '../lib/ffmpeg.mjs';
+
+export async function runPipelineA(theme, opts = {}) {
+  const {
+    title = '',
+    genre = '',
+    mood = '',
+    limit = 0,
+    aspect = '16:9',
+    shots = 8,
+    perShot = 6,
+    outFile = join(paths.out, 'mv.mp4'),
+    music = '',
+    skipMusic = false,
+    skipVideo = false,
+    dryRun = false,
+  } = opts;
+
+  ensureWork();
+  const steps = [];
+  const mark = (name, status, detail) => {
+    steps.push({ name, status, detail });
+    return status;
+  };
+
+  // ---------- 1. 作曲填词 ----------
+  log.step('[1/8] 作曲填词');
+  let song;
+  if (existsSync(paths.song) && opts.resume !== false) {
+    song = readJSON(paths.song);
+    mark('作曲填词', 'reused', `沿用 work/song.json《${song.title}》`);
+    log.ok(`沿用已有:《${song.title}》`);
+  } else {
+    song = await buildLyrics(theme, { genre, mood });
+    writeJSON(paths.song, song);
+    mark('作曲填词', 'ok', `《${song.title}》`);
+    log.ok(`《${song.title}》(${song.genre || '-'} / ${song.bpm || '?'}BPM)`);
+  }
+
+  // ---------- 2. 分镜 ----------
+  log.step('[2/8] 分镜脚本');
+  const sb = await buildStoryboardFromSong(song, { shotCount: shots, aspect, perShot });
+  writeJSON(paths.storyboard, sb);
+  writeFileSync(join(paths.work, 'storyboard.md'), renderStoryboardMd(sb), 'utf8');
+  mark('分镜脚本', 'ok', `${sb.shots.length} 镜 / ${sb.totalDuration}s`);
+  log.ok(`${sb.shots.length} 个镜头,总时长 ${sb.totalDuration}s`);
+
+  // ---------- 3. AI 音乐 ----------
+  log.step('[3/8] AI 音乐生成');
+  let musicFile = music && existsSync(music) ? music : null;
+  if (musicFile) {
+    mark('AI 音乐', 'provided', `使用外部提供:${musicFile}`);
+    log.ok(`使用指定音乐:${musicFile}`);
+  } else if (skipMusic) {
+    mark('AI 音乐', 'skipped', '--skip-music');
+    log.warn('按参数跳过');
+  } else {
+    const p = getProvider('music');
+    const pr = await p.probe({ deep: false });
+    if (pr.available === false) {
+      mark('AI 音乐', 'unavailable', pr.detail.split('\n')[0]);
+      log.warn(`通道未开通,跳过:${pr.detail}`);
+      log.dim('替代:--music <你的音频文件> / 等平台开通后重跑 / 自建兼容端点');
+    } else {
+      try {
+        const { generateMusic } = await import('./music.mjs');
+        const r = await generateMusic({ prompt: song.stylePrompt, lyrics: song.lyrics, title: song.title });
+        musicFile = r.files[0].path;
+        mark('AI 音乐', 'ok', musicFile);
+        log.ok(musicFile);
+      } catch (e) {
+        mark('AI 音乐', 'failed', e.message.split('\n')[0]);
+        log.err(`失败:${e.message.split('\n')[0]}`);
+      }
+    }
+  }
+
+  // ---------- 4. 音轨分离 ----------
+  log.step('[4/8] 人声 / 伴奏分离');
+  if (musicFile) {
+    try {
+      const { separate } = await import('./separate.mjs');
+      const r = await separate(musicFile, { mode: 'both' });
+      mark('音轨分离', 'ok', r.method === 'demucs' ? 'demucs(高质量)' : 'ffmpeg 相位兜底(近似)');
+      log.ok(`方式:${r.method}`);
+    } catch (e) {
+      mark('音轨分离', 'failed', e.message.split('\n')[0]);
+      log.err(`失败:${e.message.split('\n')[0]}`);
+    }
+  } else {
+    mark('音轨分离', 'skipped', '无音乐文件');
+    log.warn('无音乐文件,跳过');
+  }
+
+  // ---------- 5. 关键帧 ----------
+  log.step('[5/8] 场景 & 人物关键帧');
+  const kfJobs = limit ? sb.shots.slice(0, limit) : sb.shots;
+  const sbLimited = { ...sb, shots: kfJobs };
+  const kf = await generateKeyframes({ storyboard: sbLimited, outDir: paths.frames, aspect });
+  mark('关键帧', kf.failCount ? 'partial' : 'ok', `成功 ${kf.okCount} / 失败 ${kf.failCount}`);
+  log.ok(`成功 ${kf.okCount} 张,失败 ${kf.failCount} 张 → ${paths.frames}`);
+
+  // ---------- 6. 图生视频 ----------
+  log.step('[6/8] 图生视频');
+  let clipResult = { results: [], mode: 'skipped' };
+  if (skipVideo) {
+    mark('图生视频', 'skipped', '--skip-video');
+    log.warn('按参数跳过');
+  } else {
+    clipResult = await generateClips({ storyboard: sbLimited, mode: 'auto' });
+    const ok = clipResult.results.filter((x) => x.status === 'ok').length;
+    mark(
+      '图生视频',
+      clipResult.mode === 'provider' ? 'ok' : 'fallback',
+      `${clipResult.mode === 'provider' ? '真通道' : 'Ken Burns 兜底'}:${ok} 段`
+    );
+    log.ok(`${clipResult.mode === 'provider' ? '真通道' : '兜底'}:${ok} 段`);
+  }
+
+  // ---------- 7. 唇形驱动 ----------
+  log.step('[7/8] 唇形驱动');
+  const lipShots = sbLimited.shots.filter((s) => s.lipSync);
+  if (!lipShots.length) {
+    mark('唇形驱动', 'n/a', '分镜中无 lipSync:true 镜头');
+    log.dim('分镜里没有标记唇形的镜头,跳过');
+  } else {
+    const p = getProvider('lipsync');
+    const pr = await p.probe({});
+    if (!pr.available) {
+      mark('唇形驱动', 'unavailable', `${lipShots.length} 个待处理镜头未处理`);
+      log.warn(`通道不可用,${lipShots.length} 个唇形镜头未处理:${pr.detail}`);
+      log.dim('替代:自建 Wav2Lip/SadTalker 并设 FEME_LIPSYNC_ENDPOINT;或改用表情微动镜头');
+    } else {
+      try {
+        const { driveLip } = await import('./lipsync.mjs');
+        let n = 0;
+        for (const s of lipShots) {
+          const face = join(paths.frames, `shot-${String(s.id).padStart(2, '0')}.png`);
+          const audio = (musicFile && join(paths.audio, 'vocals.wav')) || join(paths.audio, 'vocals.wav');
+          if (!existsSync(face) || !existsSync(audio)) continue;
+          await driveLip(s, { faceImage: face, audio });
+          n++;
+        }
+        mark('唇形驱动', 'ok', `${n} 个镜头`);
+        log.ok(`${n} 个镜头完成`);
+      } catch (e) {
+        mark('唇形驱动', 'failed', e.message.split('\n')[0]);
+        log.err(`失败:${e.message.split('\n')[0]}`);
+      }
+    }
+  }
+
+  // ---------- 8. 剪辑合成 ----------
+  log.step('[8/8] 剪辑合成');
+  let final = null;
+  try {
+    const r = await assemble({
+      storyboard: sbLimited,
+      clipsDir: paths.clips,
+      outFile,
+      music: musicFile || undefined,
+      beatSync: true,
+    });
+    final = r.outFile;
+    mark('剪辑合成', 'ok', `${r.info.duration.toFixed(1)}s / ${humanSize(r.info.size)}`);
+  } catch (e) {
+    mark('剪辑合成', 'failed', e.message.split('\n')[0]);
+    log.err(`失败:${e.message.split('\n')[0]}`);
+  }
+
+  // ---------- 汇总 ----------
+  const report = {
+    pipeline: 'A',
+    theme,
+    title: song.title,
+    aspect,
+    shots: sb.shots.length,
+    final,
+    steps,
+    generatedAt: new Date().toISOString(),
+  };
+  writeJSON(join(paths.work, 'pipeline-a.run.json'), report);
+  printReport('产线A · AI 单曲 MV', report, { song, final });
+  return report;
+}
+
+export function printReport(name, report, extra = {}) {
+  console.log('');
+  console.log('═'.repeat(64));
+  console.log(`  ${name} · 执行报告`);
+  console.log('═'.repeat(64));
+  const icon = { ok: '✅', reused: '♻️ ', provided: '📎', fallback: '◐ ', partial: '◐ ', skipped: '⏭️ ', unavailable: '⚠️ ', failed: '❌', 'n/a': '· ' };
+  const w = Math.max(...report.steps.map((s) => s.name.length));
+  for (const s of report.steps) {
+    console.log(`  ${icon[s.status] || '  '} ${s.name.padEnd(w)}  ${s.detail || ''}`);
+  }
+  console.log('─'.repeat(64));
+  if (report.final) {
+    console.log(`  成片: ${report.final}`);
+  } else {
+    console.log('  ⚠️  未产出成片(见上方失败/跳过项)');
+  }
+  console.log('');
+  const bad = report.steps.filter((s) => s.status === 'failed' || s.status === 'unavailable');
+  if (bad.length) {
+    console.log('  未完成项:');
+    for (const b of bad) console.log(`   · ${b.name}: ${b.detail}`);
+    console.log('  详见 SKILL.md「能力现状」与 references/capability-matrix.md');
+  }
+  console.log('═'.repeat(64));
+  console.log('');
+}
+
+export async function main(argv = process.argv.slice(2)) {
+  const { _, flags } = parseArgs(argv);
+  const theme = _.join(' ').trim();
+
+  if (flags.help || !theme) {
+    console.log(`
+fmode-mv song — 产线A:AI 单曲 MV 端到端编排
+
+用法:
+  fmode-mv song "主题描述" [选项]
+
+流程:
+  作曲填词 → 分镜 → AI音乐 → 音轨分离 → 关键帧 → 图生视频 → 唇形 → 剪辑成片
+
+选项:
+  --title "歌名"     指定歌名(否则模型自定)
+  --genre "曲风"     --mood "情绪"
+  --shots <n>        镜头数(默认 8)
+  --per-shot <s>     单镜秒数(默认 6)
+  --limit <n>        只处理前 n 镜(试跑省钱)
+  --aspect 16:9|9:16|1:1
+  --music <file>     用自己已有的音乐(跳过 AI 音乐步骤)
+  --skip-music       跳过音乐步骤
+  --skip-video       跳过视频生成
+  --out <file>       成片输出(默认 out/mv.mp4)
+  --fresh            忽略已有 work/song.json,重新作曲填词
+
+示例:
+  fmode-mv song "深夜末班地铁上的城市白领" --genre "city pop" --limit 3
+  fmode-mv song "..." --music my-song.mp3
+
+⚠️ 通道现状(fmode-mv providers 可查):
+   ✅ chat / image / ffmpeg 可用;⚠️ music / video / lipsync 平台未开通。
+   未开通的步骤会如实跳过或降级兜底,并在报告中标出,绝不伪造产物。
+`);
+    return 0;
+  }
+
+  await runPipelineA(theme, {
+    title: flags.title || '',
+    genre: flags.genre || '',
+    mood: flags.mood || '',
+    limit: Number(flags.limit) || 0,
+    aspect: flags.aspect || '16:9',
+    shots: Number(flags.shots) || 8,
+    perShot: Number(flags['per-shot']) || 6,
+    outFile: String(flags.out || join(paths.out, 'mv.mp4')),
+    music: typeof flags.music === 'string' ? flags.music : '',
+    skipMusic: !!flags['skip-music'],
+    skipVideo: !!flags['skip-video'],
+    resume: !flags.fresh,
+  });
+  return 0;
+}
+
+if (import.meta.url === `file://${process.argv[1]}`) {
+  main().then((c) => process.exit(Number(c) || 0)).catch((e) => {
+    console.error(`✗ ${e.message}`);
+    if (process.env.DEBUG) console.error(e.stack);
+    process.exit(1);
+  });
+}

+ 281 - 0
scripts/pipeline-b.mjs

@@ -0,0 +1,281 @@
+// scripts/pipeline-b.mjs — 产线B 编排:AI 口播讲解视频端到端
+//
+// 口播稿 → TTS 配音 → 人物讲解画面 → 场景切换 B-roll → 剪辑合成成片
+//
+// 与产线A 共用同一套底层模块(chat / image / video / tts / ffmpeg),只是编排不同。
+
+import { existsSync, writeFileSync, mkdirSync } from 'node:fs';
+import { join } from 'node:path';
+import {
+  parseArgs, readJSON, writeJSON, paths, ensureWork, log, humanSize, chatJSON,
+} from '../lib/index.mjs';
+import { buildScriptPrompt, DEFAULT_ANCHOR } from '../lib/prompts.mjs';
+import { buildStoryboardFromScript, renderStoryboardMd } from './storyboard.mjs';
+import { generateKeyframes } from './keyframe.mjs';
+import { generateClips } from './video.mjs';
+import { assemble } from './assemble.mjs';
+import { getProvider } from '../lib/providers.mjs';
+import { printReport } from './pipeline-a.mjs';
+
+const SYSTEM = `你是资深知识类短视频编导兼口播稿写手,稿件会被 AI 语音合成(TTS)直接朗读。
+只输出 JSON,不要任何解释文字与 markdown 围栏。`;
+
+const SCHEMA = `严格输出如下 JSON(字段名不可改):
+{
+  "title": "标题(≤20字,有吸引力)",
+  "topic": "主题",
+  "durationSec": 90,
+  "hook": "开场 3 秒钩子原句",
+  "cta": "结尾行动号召原句",
+  "hostAnchor": "英文,讲者外貌锚点(性别/年龄/发型/服装/气质),逐镜头复述用",
+  "sections": [{"name":"段落名","text":"该段口播原文"}],
+  "fullText": "完整口播稿(各段拼接,用于 TTS 一次性合成)"
+}`;
+
+/** 生成口播稿 */
+export async function buildOralScript(topic, opts = {}) {
+  const { durationSec = 90, audience = '', tone = '专业但口语化', points = [], model } = opts;
+  const user =
+    buildScriptPrompt({ topic, durationSec, audience, tone, points }) + '\n\n' + SCHEMA;
+  const res = await chatJSON({
+    model,
+    system: SYSTEM,
+    messages: [{ role: 'user', content: user }],
+    temperature: 0.8,
+    max_tokens: 4096,
+  });
+  const parsed = res.json;
+  if (!parsed.fullText) {
+    parsed.fullText = (parsed.sections || []).map((s) => s.text).join('\n');
+  }
+  parsed.hostAnchor = parsed.hostAnchor || DEFAULT_ANCHOR.talk;
+  parsed._meta = { model: res.model, createdBy: 'fmode-mv/pipeline-b', topic };
+  return parsed;
+}
+
+export async function runPipelineB(topic, opts = {}) {
+  const {
+    scriptFile = '',
+    duration = 90,
+    aspect = '16:9',
+    shots = 8,
+    limit = 0,
+    outFile = join(paths.out, 'talk.mp4'),
+    voice = '',
+    music = '',
+    skipTts = false,
+    skipVideo = false,
+    audience = '',
+    points = [],
+  } = opts;
+
+  ensureWork();
+  const steps = [];
+  const mark = (name, status, detail) => steps.push({ name, status, detail });
+
+  // ---------- 1. 口播稿 ----------
+  log.step('[1/7] 口播稿');
+  let script;
+  const scriptFileOut = join(paths.work, 'script.json');
+  if (scriptFile) {
+    const { readFileSync } = await import('node:fs');
+    const text = readFileSync(scriptFile, 'utf8').trim();
+    script = { title: topic || '口播讲解', topic, fullText: text, sections: [{ name: '全文', text }], hostAnchor: DEFAULT_ANCHOR.talk };
+    mark('口播稿', 'provided', `读取 ${scriptFile}(${text.length} 字)`);
+    log.ok(`读取 ${scriptFile}`);
+  } else if (existsSync(scriptFileOut)) {
+    script = readJSON(scriptFileOut);
+    mark('口播稿', 'reused', `沿用 work/script.json《${script.title}》`);
+    log.ok(`沿用已有《${script.title}》`);
+  } else {
+    script = await buildOralScript(topic, { durationSec: duration, audience, points });
+    mark('口播稿', 'ok', `${script.fullText.length} 字 / 约 ${Math.round(script.fullText.length / 3.6)}s`);
+    log.ok(`《${script.title}》(${script.fullText.length} 字,约 ${Math.round(script.fullText.length / 3.6)} 秒)`);
+  }
+  writeJSON(scriptFileOut, script);
+  writeFileSync(join(paths.work, 'script.md'), `# ${script.title}\n\n${script.fullText}\n`, 'utf8');
+
+  // ---------- 2. TTS 配音 ----------
+  log.step('[2/7] TTS 配音');
+  let voiceFile = voice && existsSync(voice) ? voice : null;
+  if (voiceFile) {
+    mark('TTS 配音', 'provided', voiceFile);
+    log.ok(`使用指定配音:${voiceFile}`);
+  } else if (skipTts) {
+    mark('TTS 配音', 'skipped', '--skip-tts');
+    log.warn('按参数跳过');
+  } else {
+    const p = getProvider('tts');
+    const pr = await p.probe({});
+    if (!pr.available) {
+      mark('TTS 配音', 'unavailable', pr.detail.split('\n')[0]);
+      log.warn(`通道不可用:${pr.detail}`);
+      log.dim('替代:pip install edge-tts(免费本地)/ FEME_TTS_ENDPOINT 对接外部服务 / --voice 指定现成音频');
+    } else {
+      try {
+        const { synthesize } = await import('./tts.mjs');
+        const out = join(paths.audio, 'voiceover.mp3');
+        const r = await synthesize(script.fullText, { outPath: out, voice: opts.voiceName });
+        voiceFile = r.outPath;
+        mark('TTS 配音', 'ok', `${r.provider} → ${voiceFile}`);
+        log.ok(`${r.provider} → ${voiceFile}`);
+      } catch (e) {
+        mark('TTS 配音', 'failed', e.message.split('\n')[0]);
+        log.err(`失败:${e.message.split('\n')[0]}`);
+      }
+    }
+  }
+
+  // ---------- 3. 分镜 ----------
+  log.step('[3/7] 讲解分镜');
+  const sb = await buildStoryboardFromScript(script.fullText, {
+    durationSec: duration,
+    aspect,
+    shotCount: shots,
+    hostAnchor: script.hostAnchor || '',
+  });
+  sb.title = sb.title || script.title;
+  writeJSON(paths.storyboard, sb);
+  writeFileSync(join(paths.work, 'storyboard.md'), renderStoryboardMd(sb), 'utf8');
+  mark('讲解分镜', 'ok', `${sb.shots.length} 镜 / ${sb.totalDuration}s`);
+  log.ok(`${sb.shots.length} 个镜头,总时长 ${sb.totalDuration}s`);
+
+  // ---------- 4. 关键帧 ----------
+  log.step('[4/7] 讲解画面关键帧');
+  const jobs = limit ? sb.shots.slice(0, limit) : sb.shots;
+  const sbLimited = { ...sb, shots: jobs };
+  const kf = await generateKeyframes({ storyboard: sbLimited, outDir: paths.frames, aspect });
+  mark('关键帧', kf.failCount ? 'partial' : 'ok', `成功 ${kf.okCount} / 失败 ${kf.failCount}`);
+  log.ok(`成功 ${kf.okCount} 张 → ${paths.frames}`);
+
+  // ---------- 5. 场景 B-roll / 讲解画面 ----------
+  log.step('[5/7] 场景切换 B-roll / 讲解画面');
+  let clipResult = { results: [], mode: 'skipped' };
+  if (skipVideo) {
+    mark('视频片段', 'skipped', '--skip-video');
+    log.warn('按参数跳过');
+  } else {
+    clipResult = await generateClips({ storyboard: sbLimited, mode: 'auto' });
+    const ok = clipResult.results.filter((x) => x.status === 'ok').length;
+    mark('视频片段', clipResult.mode === 'provider' ? 'ok' : 'fallback', `${clipResult.mode === 'provider' ? '真通道' : 'Ken Burns 兜底'}:${ok} 段`);
+    log.ok(`${clipResult.mode === 'provider' ? '真通道' : '兜底'}:${ok} 段`);
+  }
+
+  // ---------- 6. 唇形(可选) ----------
+  const lipShots = sbLimited.shots.filter((s) => s.lipSync);
+  if (lipShots.length) {
+    log.step('[6/7] 讲者唇形驱动');
+    const p = getProvider('lipsync');
+    const pr = await p.probe({});
+    if (pr.available && voiceFile) {
+      try {
+        const { driveLip } = await import('./lipsync.mjs');
+        for (const s of lipShots) {
+          const face = join(paths.frames, `shot-${String(s.id).padStart(2, '0')}.png`);
+          if (existsSync(face)) await driveLip(s, { faceImage: face, audio: voiceFile });
+        }
+        mark('唇形驱动', 'ok', `${lipShots.length} 个镜头`);
+      } catch (e) {
+        mark('唇形驱动', 'failed', e.message.split('\n')[0]);
+      }
+    } else {
+      mark('唇形驱动', 'unavailable', `${lipShots.length} 个讲者镜头未对口型`);
+      log.warn(`通道不可用,${lipShots.length} 个讲者镜头将不对口型(观感影响有限)`);
+    }
+  }
+
+  // ---------- 7. 剪辑合成 ----------
+  log.step('[7/7] 剪辑合成');
+  let final = null;
+  try {
+    const r = await assemble({
+      storyboard: sbLimited,
+      clipsDir: paths.clips,
+      outFile,
+      voice: voiceFile || undefined,
+      bgm: music || undefined,
+      bgmVolume: music ? 0.18 : 0,
+      beatSync: false,
+    });
+    final = r.outFile;
+    mark('剪辑合成', 'ok', `${r.info.duration.toFixed(1)}s / ${humanSize(r.info.size)}`);
+  } catch (e) {
+    mark('剪辑合成', 'failed', e.message.split('\n')[0]);
+    log.err(`失败:${e.message.split('\n')[0]}`);
+  }
+
+  const report = {
+    pipeline: 'B',
+    topic,
+    title: script.title,
+    aspect,
+    shots: sb.shots.length,
+    final,
+    steps,
+    generatedAt: new Date().toISOString(),
+  };
+  writeJSON(join(paths.work, 'pipeline-b.run.json'), report);
+  printReport('产线B · AI 口播讲解视频', report);
+  return report;
+}
+
+export async function main(argv = process.argv.slice(2)) {
+  const { _, flags } = parseArgs(argv);
+  const topic = _.join(' ').trim();
+
+  if (flags.help || (!topic && !flags.script)) {
+    console.log(`
+fmode-mv talk — 产线B:AI 口播讲解视频端到端编排
+
+用法:
+  fmode-mv talk "讲解主题"
+  fmode-mv talk "主题" --points "要点1,要点2" --duration 90
+  fmode-mv talk --script my-script.md          # 用现成口播稿
+
+流程:
+  口播稿 → TTS配音 → 讲解分镜 → 关键帧 → B-roll/讲解画面 → 唇形(可选) → 剪辑成片
+
+选项:
+  --script <file>    现成口播稿(md/txt),跳过自动撰写
+  --duration <s>     目标时长(默认 90)
+  --points "a,b,c"   必须覆盖的要点(逗号分隔)
+  --audience "..."   受众描述,影响语气与用词
+  --shots <n>        镜头数(默认 8)
+  --limit <n>        只处理前 n 镜(试跑省钱)
+  --aspect 16:9|9:16|1:1
+  --voice <file>     用现成配音音频(跳过 TTS)
+  --voice-name <n>   TTS 音色名
+  --music <file>     轻背景乐
+  --skip-tts / --skip-video
+  --out <file>       成片输出(默认 out/talk.mp4)
+
+⚠️ 通道现状:TTS 平台未开通(本地装 edge-tts 即可用);视频未开通会自动兜底为运镜静帧。
+`);
+    return 0;
+  }
+
+  await runPipelineB(topic || '口播讲解', {
+    scriptFile: typeof flags.script === 'string' ? flags.script : '',
+    duration: Number(flags.duration) || 90,
+    aspect: flags.aspect || '16:9',
+    shots: Number(flags.shots) || 8,
+    limit: Number(flags.limit) || 0,
+    outFile: String(flags.out || join(paths.out, 'talk.mp4')),
+    voice: typeof flags.voice === 'string' ? flags.voice : '',
+    music: typeof flags.music === 'string' ? flags.music : '',
+    voiceName: typeof flags['voice-name'] === 'string' ? flags['voice-name'] : undefined,
+    skipTts: !!flags['skip-tts'],
+    skipVideo: !!flags['skip-video'],
+    audience: flags.audience || '',
+    points: flags.points ? String(flags.points).split(',').map((s) => s.trim()).filter(Boolean) : [],
+  });
+  return 0;
+}
+
+if (import.meta.url === `file://${process.argv[1]}`) {
+  main().then((c) => process.exit(Number(c) || 0)).catch((e) => {
+    console.error(`✗ ${e.message}`);
+    if (process.env.DEBUG) console.error(e.stack);
+    process.exit(1);
+  });
+}

+ 117 - 0
scripts/separate.mjs

@@ -0,0 +1,117 @@
+// scripts/separate.mjs — 人声 / 伴奏分离
+//
+// 两级策略:
+//   1) demucs(若已安装)—— 高质量,真·分离
+//   2) 纯 ffmpeg 兜底 —— 中置/侧信号相位处理,只能做「近似」,务必如实告知用户
+//      · instrumental:L-R 侧信号,压低居中的人声
+//      · vocals     :中置信号 (L+R)/2,突出居中人声
+// 对立体声混音有效;单声道文件无法分离,会明确报错。
+
+import { existsSync, mkdirSync } from 'node:fs';
+import { join, basename } from 'node:path';
+import { parseArgs, paths, ensureWork, log } from '../lib/index.mjs';
+import {
+  checkFfmpeg, checkDemucs, separateWithDemucs, separateByChannelPhase, probe, run, extractAudio, fileSize, humanSize,
+} from '../lib/ffmpeg.mjs';
+
+export async function separate(input, { outDir, method = 'auto', mode = 'both' } = {}) {
+  checkFfmpeg();
+  if (!existsSync(input)) throw new Error(`输入文件不存在:${input}`);
+  const dir = outDir || paths.audio;
+  mkdirSync(dir, { recursive: true });
+
+  const info = await probe(input);
+  const stem = basename(input).replace(/\.[^.]+$/, '');
+  const demucs = checkDemucs();
+  const useDemucs = method === 'demucs' || (method === 'auto' && demucs.available);
+
+  if (method === 'demucs' && !demucs.available) {
+    throw new Error('指定了 --method demucs,但未检测到 demucs。安装:pip install demucs(体积较大,非必需)');
+  }
+
+  if (useDemucs) {
+    log.info(`使用 demucs 分离(高质量,可能耗时数分钟)…`);
+    const out = await separateWithDemucs(input, dir);
+    for (const [k, v] of Object.entries(out)) {
+      if (existsSync(v)) log.ok(`${k}: ${v}(${humanSize(fileSize(v))})`);
+    }
+    return { method: 'demucs', ...out };
+  }
+
+  // ---- ffmpeg 兜底 ----
+  log.warn('未检测到 demucs,使用 ffmpeg 相位兜底方案(近似分离,非真·分离)');
+  if (!info.channels || info.channels < 2) {
+    throw new Error(
+      `输入是单声道(${info.channels || 1}ch),ffmpeg 相位法无法分离。\n` +
+        '  请改用立体声源,或安装 demucs:pip install demucs'
+    );
+  }
+
+  const result = { method: 'ffmpeg-phase', input };
+  let src = input;
+  // 统一先转 wav,避免编码问题
+  const wav = join(dir, `${stem}.src.wav`);
+  await extractAudio(input, wav, { format: 'wav' });
+  src = wav;
+
+  if (mode === 'both' || mode === 'instrumental') {
+    result.instrumental = await separateByChannelPhase(src, { outDir: dir, mode: 'instrumental' });
+    log.ok(`伴奏(近似): ${result.instrumental}(${humanSize(fileSize(result.instrumental))})`);
+  }
+  if (mode === 'both' || mode === 'vocals') {
+    result.vocals = await separateByChannelPhase(src, { outDir: dir, mode: 'vocals' });
+    log.ok(`人声(近似): ${result.vocals}(${humanSize(fileSize(result.vocals))})`);
+  }
+  result.note = 'ffmpeg 兜底分离为相位近似,人声/伴奏均可能残留串扰;正式出版建议用 demucs 或平台分离通道。';
+  return result;
+}
+
+export async function main(argv = process.argv.slice(2)) {
+  const { _, flags } = parseArgs(argv);
+  const input = flags.input || _[0];
+
+  if (flags.help || !input) {
+    console.log(`
+fmode-mv separate — 人声 / 伴奏分离(本地)
+
+用法:
+  fmode-mv separate work/audio/music.mp3
+  fmode-mv separate work/audio/music.mp3 --mode instrumental
+  fmode-mv separate work/audio/music.mp3 --method demucs    # 高质量(需已安装 demucs)
+
+选项:
+  --input <file> | 位置参数   输入音频
+  --out <dir>                输出目录(默认 work/audio)
+  --mode both|vocals|instrumental   默认 both
+  --method auto|demucs|ffmpeg       默认 auto(有 demucs 用 demucs,否则 ffmpeg 兜底)
+
+现状说明:
+  · demucs 未安装时走 ffmpeg 相位法,属「近似分离」,仅对立体声混音有效;
+  · 需要高质量分离请安装 demucs(可选,非本技能依赖)。
+`);
+    return 0;
+  }
+
+  ensureWork();
+  log.step(`音轨分离:${input}`);
+  const dm = checkDemucs();
+  log.key('demucs', dm.available ? `可用(${dm.version})` : '未安装 → 将用 ffmpeg 兜底');
+
+  const r = await separate(String(input), {
+    outDir: typeof flags.out === 'string' ? flags.out : undefined,
+    mode: flags.mode || 'both',
+    method: flags.method || 'auto',
+  });
+  if (r.note) log.warn(r.note);
+  log.key('方式', r.method);
+  return 0;
+}
+
+if (import.meta.url === `file://${process.argv[1]}`) {
+  main().then((c) => process.exit(Number(c) || 0)).catch((e) => {
+    console.error(`✗ ${e.message}`);
+    process.exit(1);
+  });
+}
+
+export { run };

+ 295 - 0
scripts/storyboard.mjs

@@ -0,0 +1,295 @@
+// scripts/storyboard.mjs — 分镜脚本
+//
+// 输入:work/song.json(产线A)或口播稿(产线B)
+// 输出:work/storyboard.json,结构见 templates/storyboard.schema.json
+//
+// 分镜是整条链路的枢纽:后面的 keyframe / video / lipsync / assemble 全部读它。
+
+import {
+  parseArgs, writeJSON, readJSON, paths, ensureWork, log, chatJSON,
+} from '../lib/index.mjs';
+import {
+  buildStoryboardPrompt, buildTalkStoryboardPrompt, DEFAULT_ANCHOR,
+} from '../lib/prompts.mjs';
+
+const SYSTEM_A = `你是顶级音乐 MV 导演兼 AI 视频提示词工程师,服务于「全程 AI 生成、无实拍」的 MV 制作。
+你的核心职责是:把一首歌拆成一个可被 AI 逐个执行的镜头清单,并保证跨镜头的人物/场景一致性。
+只输出 JSON,不要任何解释文字与 markdown 围栏。`;
+
+const SYSTEM_B = `你是顶级口播讲解视频导演兼 AI 视频提示词工程师,服务于「全程 AI 生成、无实拍」的讲解视频。
+你的核心职责是:把口播稿拆成镜头清单,让每一句口播都有对应的、语义相关的画面,并保持讲者形象一致。
+只输出 JSON,不要任何解释文字与 markdown 围栏。`;
+
+const SCHEMA_A = `严格输出如下 JSON(字段名不可改):
+{
+  "title": "与歌曲同名",
+  "aspect": "16:9",
+  "totalDuration": 180,
+  "appearanceAnchor": "英文,主角外貌锚点(性别/年龄/发型/服装/气质),逐镜头复述用",
+  "styleKeywords": "英文,全片统一影像风格关键词",
+  "shots": [
+    {
+      "id": 1,
+      "type": "broll | perf",
+      "start": 0,
+      "duration": 6,
+      "section": "Intro",
+      "description": "中文画面描述",
+      "imagePrompt": "英文,完整可独立投喂文生图的提示词",
+      "videoPrompt": "英文,图生视频的运镜/动作描述",
+      "lipSync": false,
+      "subtitle": "该镜头对应的歌词(用于字幕,可留空)",
+      "notes": "可选,中文备注"
+    }
+  ]
+}`;
+
+const SCHEMA_B = `严格输出如下 JSON(字段名不可改):
+{
+  "title": "标题",
+  "aspect": "16:9",
+  "totalDuration": 90,
+  "appearanceAnchor": "英文,讲者外貌锚点",
+  "styleKeywords": "英文,全片统一影像风格关键词",
+  "shots": [
+    {
+      "id": 1,
+      "type": "talking | broll",
+      "start": 0,
+      "duration": 8,
+      "section": "开场钩子",
+      "narration": "该镜头对应的口播原文",
+      "subtitle": "字幕文本(≤20字)",
+      "description": "中文画面描述",
+      "imagePrompt": "英文,完整可独立投喂文生图的提示词",
+      "videoPrompt": "英文,图生视频运镜描述",
+      "lipSync": false,
+      "notes": ""
+    }
+  ]
+}`;
+
+/** 归一化分镜,保证下游字段齐全 */
+export function normalizeStoryboard(sb, { aspect = '16:9', anchor = '', minShot = 3, maxShot = 10 } = {}) {
+  const shots = (sb.shots || []).map((s, i) => {
+    const dur = Number(s.duration) || 6;
+    const start = s.start !== undefined && s.start !== null ? Number(s.start) : null;
+    return {
+      id: Number(s.id) || i + 1,
+      type: s.type === 'perf' || s.type === 'talking' ? s.type : 'broll',
+      start,
+      duration: dur,
+      section: s.section || '',
+      description: s.description || s.prompt || '',
+      imagePrompt: s.imagePrompt || s.description || '',
+      videoPrompt: s.videoPrompt || 'subtle natural motion, slow cinematic push in',
+      lipSync: !!s.lipSync && ['perf', 'talking'].includes(s.type === 'perf' ? 'perf' : s.type),
+      subtitle: s.subtitle ?? s.narration ?? '',
+      narration: s.narration ?? '',
+      section: s.section || '',
+      notes: s.notes || '',
+    };
+  });
+  // 时长约束:AI 图生视频单段现实上限约 8~10 秒,模型常给出 30s+ 的不可能值。
+  // 这里按规格(每段 5~8 秒)夹取,并如实记录被夹取的镜头,绝不偷偷改。
+  let clamped = 0;
+  for (const s of shots) {
+    const before = s.duration;
+    s.duration = Math.min(maxShot, Math.max(minShot, s.duration));
+    if (Math.abs(s.duration - before) > 0.01) {
+      s._durationClampedFrom = before;
+      clamped++;
+    }
+  }
+
+  // 补齐 start,保证时间轴连续
+  let t = 0;
+  for (const s of shots) {
+    if (s.start === null || Number.isNaN(s.start)) s.start = +t.toFixed(3);
+    t = s.start + s.duration;
+  }
+  const totalDuration = shots.length ? +(shots[shots.length - 1].start + shots[shots.length - 1].duration).toFixed(3) : 0;
+  return {
+    title: sb.title || '',
+    aspect: sb.aspect || aspect,
+    totalDuration,
+    appearanceAnchor: sb.appearanceAnchor || anchor,
+    styleKeywords: sb.styleKeywords || '',
+    shots,
+    _meta: {
+      createdBy: 'fmode-mv/storyboard',
+      generatedAt: new Date().toISOString(),
+      durationClamped: clamped,
+    },
+  };
+}
+
+/** 产线A:从歌曲生成 MV 分镜 */
+export async function buildStoryboardFromSong(song, opts = {}) {
+  const { shotCount = 8, aspect = '16:9', perShot = 6, model, project = '' } = opts;
+  const prompt = buildStoryboardPrompt({ song, shotCount, aspect, perShot, project }) + '\n\n' + SCHEMA_A;
+  const res = await chatJSON({
+    model,
+    system: SYSTEM_A,
+    messages: [{ role: 'user', content: prompt }],
+    temperature: 0.85,
+    max_tokens: 12000,
+  });
+  const sb = normalizeStoryboard(res.json, { aspect });
+  sb._meta = { ...sb._meta, model: res.model, maxTokensUsed: res.maxTokensUsed, repaired: !!res.repaired };
+  return sb;
+}
+
+/** 产线B:从口播稿生成讲解分镜 */
+export async function buildStoryboardFromScript(script, opts = {}) {
+  const { durationSec = 90, aspect = '16:9', shotCount = 8, model, project = '', hostAnchor = '' } = opts;
+  const prompt =
+    buildTalkStoryboardPrompt({ script, durationSec, aspect, shotCount, project, hostAnchor }) + '\n\n' + SCHEMA_B;
+  const res = await chatJSON({
+    model,
+    system: SYSTEM_B,
+    messages: [{ role: 'user', content: prompt }],
+    temperature: 0.8,
+    max_tokens: 12000,
+  });
+  const sb = normalizeStoryboard(res.json, { aspect, anchor: DEFAULT_ANCHOR.talk });
+  sb._meta = { ...sb._meta, model: res.model, maxTokensUsed: res.maxTokensUsed, repaired: !!res.repaired };
+  return sb;
+}
+
+export async function main(argv = process.argv.slice(2)) {
+  const { _, flags } = parseArgs(argv);
+
+  if (flags.help || (!_.length && !flags.from && !flags.script)) {
+    console.log(`
+fmode-mv storyboard — 分镜脚本(chat 通道)
+
+产线A(歌曲 MV):
+  fmode-mv storyboard --from work/song.json [--shots 8] [--aspect 16:9] [--per-shot 6]
+
+产线B(口播视频):
+  fmode-mv storyboard --script work/script.md [--duration 90] [--shots 8]
+
+选项:
+  --from <file>     歌曲 JSON(产线A)
+  --script <file>   口播稿文本文件(产线B)
+  --shots <n>       镜头数(默认 8)
+  --per-shot <s>    单镜头秒数,仅产线A(默认 6)
+  --duration <s>    口播总时长,仅产线B(默认 90)
+  --aspect <r>      16:9 | 9:16 | 1:1(默认 16:9)
+  --project "..."   项目背景,帮助模型贴合语境
+  --model <name>    覆盖 LLM 模型
+  --out <file>      输出(默认 work/storyboard.json)
+  --json            只打印 JSON
+`);
+    return 0;
+  }
+
+  ensureWork();
+  const aspect = flags.aspect || '16:9';
+  let sb;
+
+  if (flags.script) {
+    const { readFileSync } = await import('node:fs');
+    const script = readFileSync(String(flags.script), 'utf8');
+    if (!script.trim()) throw new Error(`口播稿为空:${flags.script}`);
+    log.step('生成口播分镜(产线B)');
+    log.key('口播稿', `${script.length} 字`);
+    sb = await buildStoryboardFromScript(script, {
+      durationSec: Number(flags.duration) || 90,
+      aspect,
+      shotCount: Number(flags.shots) || 8,
+      project: flags.project || '',
+      model: flags.model,
+    });
+  } else {
+    const from = typeof flags.from === 'string' ? flags.from : paths.song;
+    const song = readJSON(from);
+    if (!song) throw new Error(`未找到歌曲文件 ${from}。先跑:fmode-mv lyrics "主题"`);
+    log.step(`生成 MV 分镜(产线A):《${song.title}》`);
+    sb = await buildStoryboardFromSong(song, {
+      shotCount: Number(flags.shots) || 8,
+      aspect,
+      perShot: Number(flags.per_shot) || 6,
+      project: flags.project || '',
+      model: flags.model,
+    });
+  }
+
+  const outFile = typeof flags.out === 'string' ? flags.out : paths.storyboard;
+  writeJSON(outFile, sb);
+  const { writeFileSync } = await import('node:fs');
+  const { join } = await import('node:path');
+  writeFileSync(join(paths.work, 'storyboard.md'), renderStoryboardMd(sb), 'utf8');
+
+  if (flags.json) {
+    console.log(JSON.stringify(sb, null, 2));
+    return 0;
+  }
+
+  console.log(renderStoryboardMd(sb));
+  log.ok(`已写入 ${outFile} 与 work/storyboard.md`);
+
+  if (sb._meta?.durationClamped) {
+    log.warn(
+      `${sb._meta.durationClamped} 个镜头的时长超出合理区间(${3}~${10}s)已被夹取 —— ` +
+        'AI 图生视频单段现实上限约 8~10 秒,模型给出的几十秒镜头无法直接生成。'
+    );
+  }
+  const perfs = sb.shots.filter((s) => s.lipSync).length;
+  const brolls = sb.shots.filter((s) => !s.lipSync).length;
+  log.key('镜头', `${sb.shots.length} 个(空镜/场景 ${brolls} + 唇形 ${perfs})`);
+  log.key('总时长', `${sb.totalDuration}s`);
+  if (!sb.appearanceAnchor) log.warn('缺少 appearanceAnchor(人物一致性锚点),跨镜头可能换脸');
+  return 0;
+}
+
+export function renderStoryboardMd(sb) {
+  const lines = [];
+  lines.push(`# 分镜表 · ${sb.title}`);
+  lines.push('');
+  lines.push(`- 画面比例:${sb.aspect}`);
+  lines.push(`- 总时长:${sb.totalDuration}s`);
+  lines.push(`- 影像风格:${sb.styleKeywords || '-'}`);
+  lines.push(`- 人物锚点:${sb.appearanceAnchor || '-'}`);
+  lines.push('');
+  lines.push('| # | 时间码 | 时长 | 类型 | 段落 | 画面 | 唇形 |');
+  lines.push('|---|--------|------|------|------|------|------|');
+  for (const s of sb.shots) {
+    const tc = `${fmt(s.start)}–${fmt(s.start + s.duration)}`;
+    lines.push(
+      `| ${s.id} | ${tc} | ${s.duration}s | ${s.type} | ${s.section || '-'} | ${oneLine(s.description)} | ${s.lipSync ? '✓' : ''} |`
+    );
+  }
+  lines.push('');
+  lines.push('## 镜头明细');
+  for (const s of sb.shots) {
+    lines.push('');
+    lines.push(`### #${s.id} · ${fmt(s.start)}–${fmt(s.start + s.duration)} · ${s.type}`);
+    lines.push(`- 画面:${s.description}`);
+    if (s.narration) lines.push(`- 口播:${s.narration}`);
+    if (s.subtitle) lines.push(`- 字幕:${s.subtitle}`);
+    lines.push(`- 图 prompt:\`${s.imagePrompt}\``);
+    lines.push(`- 运镜 prompt:\`${s.videoPrompt}\``);
+  }
+  return lines.join('\n') + '\n';
+}
+
+function fmt(sec) {
+  const s = Math.max(0, Number(sec) || 0);
+  const m = Math.floor(s / 60);
+  const r = (s % 60).toFixed(1).padStart(4, '0');
+  return `${String(m).padStart(2, '0')}:${r}`;
+}
+
+function oneLine(s, n = 34) {
+  const t = String(s || '').replace(/[\r\n|]/g, ' ').trim();
+  return t.length > n ? t.slice(0, n) + '…' : t;
+}
+
+if (import.meta.url === `file://${process.argv[1]}`) {
+  main().then((c) => process.exit(Number(c) || 0)).catch((e) => {
+    console.error(`✗ ${e.message}`);
+    process.exit(1);
+  });
+}

+ 110 - 0
scripts/tts.mjs

@@ -0,0 +1,110 @@
+// scripts/tts.mjs — 口播配音(provider: tts)
+//
+// ⚠️ 现状:平台暂无 TTS 通道。
+// 但本地 edge-tts(若已安装)可直接使用 —— probe 会优先探测本地。
+// 两条路都不可用时,明确抛出 ProviderUnavailableError + 三条替代方案。
+
+import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
+import { join } from 'node:path';
+import {
+  parseArgs, readJSON, writeJSON, paths, ensureWork, log,
+} from '../lib/index.mjs';
+import { getProvider } from '../lib/providers.mjs';
+import { probe as ffprobe } from '../lib/ffmpeg.mjs';
+
+export async function synthesize(text, { outPath, voice } = {}) {
+  const p = getProvider('tts');
+  const dir = paths.audio;
+  mkdirSync(dir, { recursive: true });
+  const out = outPath || join(dir, 'voiceover.mp3');
+  const r = await p.call({ text, outPath: out, voice });
+  return r;
+}
+
+export async function main(argv = process.argv.slice(2)) {
+  const { _, flags } = parseArgs(argv);
+
+  if (flags.help || (!flags.text && !flags.from && !flags.probe)) {
+    console.log(`
+fmode-mv tts — 口播配音(tts 通道)
+
+用法:
+  fmode-mv tts --probe                              # 探活(含本地 edge-tts 探测)
+  fmode-mv tts --text "大家好,今天讲三件事。" --out work/audio/voiceover.mp3
+  fmode-mv tts --from work/script.md                # 整个口播稿合成
+
+选项:
+  --text "..."       直接给文本
+  --from <file>      口播稿文件(纯文本/md)
+  --voice <name>     音色(默认 zh-CN-XiaoxiaoNeural,edge-tts 音色名)
+  --out <file>       输出文件(默认 work/audio/voiceover.mp3)
+  --probe            只探活
+
+⚠️ 现状:平台暂无 TTS 通道。替代方案:
+  A) 本地 edge-tts(推荐,免费):pip install edge-tts   安装后本命令自动使用
+  B) 对接 OpenAI 兼容 /v1/audio/speech:
+     export FEME_TTS_ENDPOINT=https://your-host/v1/audio/speech
+  C) 自备配音音频放到 work/audio/voiceover.mp3,用 --from 跳过本步
+`);
+    return 0;
+  }
+
+  const p = getProvider('tts');
+  const pr = await p.probe({});
+
+  if (flags.probe) {
+    log.step('TTS 通道探活');
+    log.key('状态', pr.available ? '可用' : '不可用');
+    log.key('提供方', pr.provider || '平台网关');
+    log.info(pr.detail);
+    return pr.available ? 0 : 3;
+  }
+
+  if (!pr.available) {
+    throw Object.assign(new Error(`TTS 通道不可用:${pr.detail}`), { hint: p.hint, name: 'ProviderUnavailableError' });
+  }
+
+  ensureWork();
+  let text = flags.text ? String(flags.text) : null;
+  if (!text && flags.from) {
+    if (!existsSync(String(flags.from))) throw new Error(`文件不存在:${flags.from}`);
+    text = readFileSync(String(flags.from), 'utf8')
+      .replace(/^#{1,6}\s+/gm, '')
+      .replace(/[*_`>]/g, '')
+      .trim();
+  }
+  if (!text) throw new Error('缺少文本:用 --text 或 --from');
+
+  log.step(`合成配音(${pr.provider || '网关'})`);
+  log.key('字数', `${text.length} 字`);
+  log.key('音色', flags.voice || p.defaultVoice());
+
+  const out = typeof flags.out === 'string' ? flags.out : join(paths.audio, 'voiceover.mp3');
+  const r = await synthesize(text, { outPath: out, voice: flags.voice });
+  const info = await ffprobe(r.outPath).catch(() => null);
+  log.ok(`→ ${r.outPath}${info ? `(${info.duration.toFixed(1)}s)` : ''}`);
+
+  writeJSON(join(paths.audio, 'voiceover.json'), {
+    text,
+    voice: r.voice,
+    provider: r.provider,
+    path: r.outPath,
+    duration: info?.duration ?? null,
+    _meta: { createdBy: 'fmode-mv/tts', generatedAt: new Date().toISOString() },
+  });
+  return 0;
+}
+
+if (import.meta.url === `file://${process.argv[1]}`) {
+  main().then((c) => process.exit(Number(c) || 0)).catch(async (e) => {
+    const { reportUnavailable, log: L } = await import('../lib/index.mjs');
+    if (e?.name === 'ProviderUnavailableError' || e?.hint) {
+      reportUnavailable('tts', e);
+      process.exit(3);
+    }
+    L.err(e.message);
+    process.exit(1);
+  });
+}
+
+export { readJSON, writeFileSync };

+ 230 - 0
scripts/video.mjs

@@ -0,0 +1,230 @@
+// scripts/video.mjs — 图生视频(provider: video)
+//
+// ⚠️ 现状:平台视频渠道未开通(实测 POST /v1/video/generations 返回 503
+//    "No available channel for model doubao-seedance-1-0-pro")。
+//
+// 两条路径:
+//   · 真通道:provider.call → 异步 task 轮询 → 下载 MP4
+//   · 兜底(--fallback-still,默认开启于 auto 模式):把关键帧做成 Ken Burns 动态镜头
+//     兜底产物文件名带 .fallback 标记,绝不冒充真视频生成结果。
+
+import { existsSync, mkdirSync, writeFileSync, readdirSync, statSync } from 'node:fs';
+import { join } from 'node:path';
+import {
+  parseArgs, readJSON, writeJSON, paths, ensureWork, log, invoke, download, humanSize,
+} from '../lib/index.mjs';
+import { getProvider } from '../lib/providers.mjs';
+import { buildVideoPrompt, aspectToVideoSize } from '../lib/prompts.mjs';
+import { kenBurns, probe as ffprobe, checkFfmpeg } from '../lib/ffmpeg.mjs';
+
+/** 从 provider 结果里提取视频 URL */
+function extractVideoUrls(result) {
+  const urls = [];
+  const walk = (o, depth = 0) => {
+    if (!o || depth > 6) return;
+    if (typeof o === 'string' && /^https?:\/\/.+\.(mp4|mov|webm|mkv)(\?.*)?$/i.test(o)) urls.push(o);
+    else if (Array.isArray(o)) o.forEach((x) => walk(x, depth + 1));
+    else if (typeof o === 'object') {
+      for (const [k, v] of Object.entries(o)) {
+        if (/video_url|url|video|download/i.test(k) && typeof v === 'string' && /^https?:\/\/.+\.(mp4|mov|webm)/i.test(v)) urls.push(v);
+        else walk(v, depth + 1);
+      }
+    }
+  };
+  walk(result);
+  return [...new Set(urls)];
+}
+
+/**
+ * 为分镜里的镜头生成视频片段。
+ * @param {object} o
+ * @param {object} o.storyboard
+ * @param {string} [o.keyframesDir]
+ * @param {'auto'|'provider'|'still'} [o.mode]
+ * @param {number} [o.shotId]
+ * @param {number} [o.limit]
+ * @param {boolean} [o.force]  兜底模式下也重跑已存在的文件
+ */
+export async function generateClips(o = {}) {
+  const {
+    storyboard,
+    keyframesDir = paths.frames,
+    clipsDir = paths.clips,
+    mode = 'auto',
+    shotId,
+    limit,
+    force = false,
+    aspect = storyboard?.aspect || '16:9',
+    onProviderError,
+  } = o;
+
+  if (!storyboard) throw new Error('generateClips 需要 storyboard');
+  mkdirSync(clipsDir, { recursive: true });
+
+  const size = aspectToVideoSize(aspect);
+  const provider = getProvider('video');
+
+  // 决定用哪条路:auto 模式下先探活,不通就直接走兜底,避免每镜都白等
+  let useProvider = mode === 'provider';
+  let probeNote = null;
+  if (mode === 'auto') {
+    const p = await provider.probe({ deep: false });
+    useProvider = p.available === true;
+    probeNote = p.detail;
+    if (!useProvider) {
+      log.warn(`视频通道不可用:${p.detail}`);
+      log.warn('转为兜底模式:关键帧 + Ken Burns 运镜(产物带 .fallback 标记,非真视频生成)');
+    }
+  }
+  if (mode === 'still') useProvider = false;
+
+  let shots = storyboard.shots.filter((s) => !shotId || s.id === Number(shotId));
+  if (limit) shots = shots.slice(0, Number(limit));
+
+  const results = [];
+  for (const shot of shots) {
+    const name = `shot-${String(shot.id).padStart(2, '0')}`;
+    const still = join(keyframesDir, `${name}.png`);
+    const out = join(clipsDir, `${name}.mp4`);
+    const vPrompt = buildVideoPrompt(shot);
+
+    if (existsSync(out) && !force) {
+      log.dim(`[${name}] 已存在,跳过(--force 覆盖)`);
+      results.push({ id: shot.id, file: out, status: 'skipped', mode: useProvider ? 'provider' : 'still' });
+      continue;
+    }
+
+    if (useProvider) {
+      if (!existsSync(still)) {
+        log.err(`[${name}] 缺少关键帧 ${still},先跑 fmode-mv keyframe`);
+        results.push({ id: shot.id, file: null, status: 'failed', error: '缺少关键帧' });
+        continue;
+      }
+      try {
+        log.info(`[${name}] 图生视频(${shot.duration}s,${size})…`);
+        const { taskId, result } = await provider.call({
+          image: still,
+          prompt: vPrompt,
+          duration: Math.min(8, Math.max(3, Math.round(shot.duration))),
+          size,
+        });
+        const urls = extractVideoUrls(result);
+        if (!urls.length) throw new Error(`任务成功但未返回视频 URL:${JSON.stringify(result).slice(0, 300)}`);
+        const buf = await download(urls[0]);
+        writeFileSync(out, buf);
+        log.ok(`→ ${out}(${humanSize(buf.length)})`);
+        results.push({ id: shot.id, file: out, status: 'ok', mode: 'provider', taskId, bytes: buf.length });
+      } catch (e) {
+        log.err(`[${name}] 视频通道失败:${e.message.split('\n')[0]}`);
+        if (onProviderError) onProviderError(e, shot);
+        results.push({ id: shot.id, file: null, status: 'failed', error: e.message, mode: 'provider' });
+      }
+      continue;
+    }
+
+    // ---- 兜底:Ken Burns ----
+    if (!existsSync(still)) {
+      log.err(`[${name}] 缺少关键帧 ${still},先跑 fmode-mv keyframe`);
+      results.push({ id: shot.id, file: null, status: 'failed', error: '缺少关键帧' });
+      continue;
+    }
+    const fbOut = join(clipsDir, `${name}.fallback.mp4`);
+    const dir = shot.id % 3 === 0 ? 'out' : 'in';
+    try {
+      await kenBurns(still, fbOut, {
+        duration: shot.duration,
+        width: Number(String(size).split('x')[0]),
+        height: Number(String(size).split('x')[1]),
+        direction: dir,
+        zoom: 0.1,
+      });
+      const st = statSync(fbOut);
+      log.ok(`→ ${fbOut}(${humanSize(st.size)},Ken Burns 兜底)`);
+      results.push({ id: shot.id, file: fbOut, status: 'ok', mode: 'still', bytes: st.size, fallback: true });
+    } catch (e) {
+      log.err(`[${name}] 兜底生成失败:${e.message.split('\n')[0]}`);
+      results.push({ id: shot.id, file: null, status: 'failed', error: e.message, mode: 'still' });
+    }
+  }
+
+  writeJSON(join(clipsDir, 'clips.json'), {
+    mode: useProvider ? 'provider' : 'still',
+    probeNote,
+    size,
+    clips: results,
+    _meta: { createdBy: 'fmode-mv/video', generatedAt: new Date().toISOString() },
+  });
+
+  return { results, mode: useProvider ? 'provider' : 'still', clipsDir };
+}
+
+export async function main(argv = process.argv.slice(2)) {
+  const { _, flags } = parseArgs(argv);
+
+  if (flags.help || !flags.from) {
+    console.log(`
+fmode-mv video — 图生视频(video 通道)
+
+用法:
+  fmode-mv video --from work/storyboard.json                    # auto:能走通道就走,否则兜底
+  fmode-mv video --from work/storyboard.json --mode provider    # 只走真通道(不通即失败)
+  fmode-mv video --from work/storyboard.json --mode still       # 只用关键帧做 Ken Burns 兜底
+  fmode-mv video --from work/storyboard.json --shot 3
+
+选项:
+  --from <file>      分镜 JSON
+  --mode auto|provider|still    默认 auto
+  --shot <id>        只做指定镜头
+  --limit <n>        最多 n 镜
+  --frames <dir>     关键帧目录(默认 work/frames)
+  --out <dir>        输出目录(默认 work/clips)
+  --model <name>     覆盖视频模型
+  --force            覆盖已存在的片段
+
+⚠️ 现状:视频渠道未开通(upstream 无可用渠道)。
+   auto 模式会自动降级为「静帧 + Ken Burns 运镜」,产物名带 .fallback,
+   用于先出片、后替换。真通道开通后 auto 会自动切回。
+`);
+    return 0;
+  }
+
+  checkFfmpeg();
+  ensureWork();
+  const from = String(flags.from);
+  if (!existsSync(from)) throw new Error(`分镜文件不存在:${from}`);
+  const storyboard = readJSON(from);
+
+  log.step(`生成视频片段:${storyboard.title || ''}(共 ${storyboard.shots.length} 镜)`);
+  const r = await generateClips({
+    storyboard,
+    keyframesDir: String(flags.frames || paths.frames),
+    clipsDir: String(flags.out || paths.clips),
+    mode: flags.mode || 'auto',
+    shotId: flags.shot ? Number(flags.shot) : undefined,
+    limit: flags.limit ? Number(flags.limit) : undefined,
+    force: !!flags.force,
+  });
+
+  const ok = r.results.filter((x) => x.status === 'ok').length;
+  const fb = r.results.filter((x) => x.fallback).length;
+  const skipped = r.results.filter((x) => x.status === 'skipped').length;
+  const failed = r.results.filter((x) => x.status === 'failed').length;
+  log.key('模式', r.mode === 'provider' ? '真通道' : '兜底(Ken Burns)');
+  log.key('结果', `成功 ${ok}(其中兜底 ${fb})/ 跳过 ${skipped} / 失败 ${failed}`);
+
+  if (r.mode === 'still' && fb > 0) {
+    console.log('');
+    log.warn('注意:本次所有片段都是「静帧 + 运镜」兜底产物,不是 AI 图生视频结果。');
+    log.dim('真通道开通后重跑 `fmode-mv video --from work/storyboard.json --mode provider --force` 即可替换。');
+  }
+  return failed > 0 && ok === 0 ? 1 : 0;
+}
+
+if (import.meta.url === `file://${process.argv[1]}`) {
+  main().then((c) => process.exit(Number(c) || 0)).catch((e) => {
+    console.error(`✗ ${e.message}`);
+    process.exit(1);
+  });
+}
+
+export { ffprobe };

+ 110 - 0
templates/storyboard.schema.json

@@ -0,0 +1,110 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema#",
+  "$id": "https://git.fmode.cn/fmode-13177980648/skill-mv/templates/storyboard.schema.json",
+  "title": "fmode-mv storyboard",
+  "description": "分镜数据结构。产线A(音乐MV)与产线B(口播讲解)共用,差异在 shots[].type 与 narration 字段。整条链路(keyframe / video / lipsync / assemble)都消费本结构。",
+  "type": "object",
+  "required": ["title", "aspect", "totalDuration", "shots"],
+  "additionalProperties": true,
+  "properties": {
+    "title": {
+      "type": "string",
+      "description": "标题:产线A 为歌名,产线B 为视频标题"
+    },
+    "aspect": {
+      "type": "string",
+      "enum": ["16:9", "9:16", "1:1", "4:3"],
+      "default": "16:9",
+      "description": "画面比例,决定关键帧尺寸与成片分辨率"
+    },
+    "totalDuration": {
+      "type": "number",
+      "description": "全片总时长(秒),由各镜头 start+duration 推导"
+    },
+    "appearanceAnchor": {
+      "type": "string",
+      "description": "★人物一致性锚点(英文)。每个含人物的镜头都必须原样复述这段描述,否则跨镜头会换脸。",
+      "examples": [
+        "Asian woman in her late 20s, shoulder-length black hair with soft waves, natural makeup, wearing a deep navy oversized jacket over a white tee, calm melancholic gaze"
+      ]
+    },
+    "styleKeywords": {
+      "type": "string",
+      "description": "全片统一的影像风格关键词(英文),会拼进每张关键帧 prompt",
+      "examples": ["cinematic, teal and orange color grade, neon city night, shallow depth of field, 35mm film grain"]
+    },
+    "shots": {
+      "type": "array",
+      "minItems": 1,
+      "description": "镜头列表,按时间顺序",
+      "items": {
+        "type": "object",
+        "required": ["id", "type", "start", "duration", "description", "imagePrompt", "videoPrompt"],
+        "additionalProperties": true,
+        "properties": {
+          "id": {
+            "type": "integer",
+            "minimum": 1,
+            "description": "镜头序号,从 1 开始。对应产物文件名 shot-XX.png / shot-XX.mp4"
+          },
+          "type": {
+            "type": "string",
+            "enum": ["broll", "perf", "talking"],
+            "description": "broll=空镜/场景(无人物主体);perf=产线A 人物演唱镜头;talking=产线B 讲者镜头"
+          },
+          "start": {
+            "type": "number",
+            "minimum": 0,
+            "description": "起始时间码(秒)"
+          },
+          "duration": {
+            "type": "number",
+            "minimum": 1,
+            "description": "镜头时长(秒)。AI 图生视频单段建议 5~8 秒。"
+          },
+          "section": {
+            "type": "string",
+            "description": "所属段落:产线A 为 [Verse 1]/[Chorus] 等;产线B 为「开场钩子」等"
+          },
+          "narration": {
+            "type": "string",
+            "description": "产线B 专用:该镜头对应的口播原文"
+          },
+          "subtitle": {
+            "type": "string",
+            "description": "该镜头字幕文本(建议 ≤20 字,单行显示)"
+          },
+          "description": {
+            "type": "string",
+            "description": "中文画面描述(给人看的分镜表正文)"
+          },
+          "imagePrompt": {
+            "type": "string",
+            "description": "★英文提示词,必须可独立投喂文生图模型:主体+动作+环境+光线+镜头语言+风格+画质词;含人物时须包含 appearanceAnchor 的英文版"
+          },
+          "videoPrompt": {
+            "type": "string",
+            "description": "★英文运镜描述,供图生视频:主体动作 + 镜头运动(push in / pull back / pan / orbit / handheld)+ 节奏。不含画面内容(内容来自关键帧)。"
+          },
+          "lipSync": {
+            "type": "boolean",
+            "default": false,
+            "description": "是否对该镜头做唇形驱动。仅对 perf/talking 有效;全片建议 ≤4 个(成本和口型风险)"
+          },
+          "notes": {
+            "type": "string",
+            "description": "中文备注(可选)"
+          }
+        }
+      }
+    },
+    "_meta": {
+      "type": "object",
+      "description": "生成元信息(自动写入,勿手工编辑)",
+      "properties": {
+        "createdBy": { "type": "string" },
+        "generatedAt": { "type": "string", "format": "date-time" }
+      }
+    }
+  }
+}