feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s

移植自 mac-agent-os 的 mediacrawler_adapter:不起子进程、不开页面,用浏览器里那份
登录态直接调抖音 Web 接口。产物键名照抄 store/douyin,所以 ingest 那条链路一个字不用改。

**目前能用的(真环境实测,非推断)**:

    profile/other : 200, 7075 字节  —— 博主主页指标(粉丝/获赞/作品数/昵称)
    aweme/detail  : 200, 45425 字节 —— 单条作品详情(含点赞/评论/收藏/分享)

**目前不能用的:作品列表 `aweme/post`。** 两个互相独立的原因:
  1. 这个接口被抖音单独升级成了真校验:不带 x-tt-argus 回 403「Uifid Not Found」,
     带上 dummy 值回 200 + **空 body**。也就是说「头在不在」骗得过,「真校验」过不了。
     同一套头打 profile/other 和 aweme/detail 都是通的 —— 抖音是挑着接口加保护的,
     挑中的恰好是「批量拉作品列表」这个最敏感的动作。
  2. 改走页面截获也不行:CDP 浏览器打开博主主页会落到「验证码中间页」(当天大量探测的
     代价,过几小时要重测)。

  所以现在的边界是:**已知作品的指标刷新能做,自动发现新作品做不了**。

**排查中控住变量后得到的两条事实**(都写进注释了):
  · `Accept` / `Accept-Language` / `Referer` 才是主页接口能返回真数据的原因 —— 只有
    UA+client hints+Cookie 时是 200 但仅 121 字节的空壳,补上这三个头变 7074 字节。
    (我先前猜的 sec-ch-ua 不是关键。)
  · 因此 UA 与 client hints 必须**成套地取自同一个浏览器**,所以 BrowserIdentity 一次
    从 CDP 取齐 cookie + UA + hints,而不是各自写死。

「200 + 空 body 必须当场报错」也是刻意写死的:放过去它会在下游变成「这个博主没作品」,
把一次失败伪装成一条正常结果 —— 爬虫那条路正是这么栽的,还被翻译成「账号被封」。

测试 +11:cookie 解析、请求头成套性(含 uifid 缺失/回退)、产物键名与 store 对齐、
以及 _get 的三条失败路径(空 body / 403 带网关原话 / 正常返回)。
This commit is contained in:
2026-10-10 17:12:25 +08:00
parent 21ff01b894
commit 2112c1a870
1601 changed files with 367425 additions and 0 deletions
@@ -0,0 +1,480 @@
# AVE 视频工厂 — 全链路架构规划 v1.0
> 日期: 2026-05-18 | 版本: 1.0
> 定位: 梳理所有生产路径 + 底层能力原子化 + 自审可行性
---
## 目录
1. [核心洞察](#1-核心洞察)
2. [AVE 六条生产路径总览](#2-ave-六条生产路径总览)
3. [底层能力层(原子化)](#3-底层能力层原子化)
4. [各生产路径的完整链路](#4-各生产路径的完整链路)
5. [新增:模仿视频线(Action Transfer)](#5-新增模仿视频线action-transfer)
6. [交叉能力矩阵](#6-交叉能力矩阵)
7. [优先级与落地路线图](#7-优先级与落地路线图)
8. [自审与可行性分析](#8-自审与可行性分析)
9. [具体落地建议](#9-具体落地建议)
---
## 1. 核心洞察
### 1.1 用户提到的三条线本质是同一逻辑
| 用户说的 | 本质 | 核心操作 |
|:---------|:-----|:---------|
| 卡点视频 | 音频节拍 → 视觉同步切换 | BPM分析 + 拍点对齐 + 素材切换 |
| 模仿视频 | 参考视频 → 提取节奏/动作 → 复用结构 | 参考视频分析 + 动作迁移 + 结构复用 |
| 主人公更换 | 已有视频 → 替换角色 → 保持动作/场景 | 角色识别 + 角色替换 + 动作保持 |
**三者共享同一条底层流水线**: `参考源分析 → 结构提取 → 角色/素材替换 → 重新合成`
### 1.2 AVE 当前状态
已实现 5 条独立生产路径,但存在三个结构性问题:
| 问题 | 后果 |
|:----|:-----|
| 各策略独立实现,底层能力重复 | 口播的素材搜索和卡点的素材搜索各写一遍 |
| 角色系统只在"故事"策略中使用 | 口播、卡点、数字人都需要角色但没接入 |
| AudioLine 刚刚建成但未接入主流程 | 音频处理能力独立于视频工厂 |
### 1.3 解决思路
**分层架构**: 底层能力层(原子操作) → 策略编排层(生产路径) → 接入层(CLI/Dashboard)
---
## 2. AVE 六条生产路径总览
```
AVE 视频工厂
│
├─ 1. 口播线 文案→TTS→素材→BGM→字幕→合成
├─ 2. 卡点线 BGM→节拍检测→素材→拍点切换→xfade
├─ 3. 数字人线 照片/视频→OmniHuman对口型/DreamActor动作模仿
├─ 4. 口播+卡点线 人声锚点+BGM变速→帧锁定拼接 (hybrid)
├─ 5. 故事/漫剧线 剧本→场景分解→Kling批量→角色拼接
├─ 6. 模仿视频线 ★ 参考视频→结构分析→动作迁移/角色替换→合成
│
└─ 7. 音频制造线 (独立)
├─ 原创 AudioLine Layer3 创作模块
├─ 二创 分层改编 (节奏/歌词/旋律/混音)
├─ 卡点改编 节拍重塑 + BPM对齐
└─ → 接入上述 1-6 条线输出成品
```
---
## 3. 底层能力层(原子化)
> 所有生产路径共享的原子能力。每个能力可独立测试、独立优化、被多种策略调用。
### 3.1 音频原子能力 ← AudioLine
| 原子能力 | 输入 | 输出 | 被哪些路径使用 |
|:---------|:-----|:-----|:-------------|
| A1. 音轨分离 | 音频文件 | {vocals, drums, bass, other} 分轨 | 2-卡点, 4-hybrid, 6-模仿, 7-音频 |
| A2. BPM/节拍检测 | 音频文件 | BPM + 节拍时间戳 + 重拍 | 2-卡点, 4-hybrid, 6-模仿 |
| A3. 歌曲结构解析 | 音频文件 | [{label, start, end}] 段落 | 6-模仿, 7-二创 |
| A4. 情绪分析 | 音频文件 | mood + energy + danceability | 1-口播(BGM选曲), 5-故事(风格匹配) |
| A5. 节奏生成 | BPM+风格参数 | 鼓点/节奏音频 | 2-卡点, 7-原创 |
| A6. 歌词创作 | 原歌词 + 主题 | 改写歌词 | 7-二创 |
| A7. 旋律改编 | 音频 + 风格 | 改编旋律 | 7-二创 |
| A8. 混音母带 | 多轨音频 | 混合主轨 + 响度归一 | 1-口播, 2-卡点, 7-原创/二创 |
| A9. 分轨时间对齐 | 分轨 + 目标BPM | 对齐后分轨 | 2-卡点, 6-模仿 |
| A10. 歌词-节拍对齐 | 歌词 + 节拍 | 时间轴 + LRC/SRT | 1-口播, 2-卡点 |
| A11. 多格式导出 | 音频 + 格式 | WAV/MP3/FLAC/AAC | 全部 |
### 3.2 视觉原子能力
| 原子能力 | 输入 | 输出 | 被哪些路径使用 |
|:---------|:-----|:-----|:-------------|
| V1. 素材搜索 | 关键词 + 数量 | 视频素材列表(URL/本地) | 1-口播, 2-卡点, 4-hybrid |
| V2. Ken Burns 效果 | 静态图片 | 缓慢缩放/平移视频 | 1-口播, 5-故事 |
| V3. 节拍视觉对齐 | 素材 + 节拍 | 按拍点切割/过渡的视频段 | 2-卡点, 4-hybrid, 6-模仿 |
| V4. 变速卡点 | 素材 + 节拍能量 | 高潮加速/低谷减速 | 4-hybrid |
| V5. 视频拼接 | 视频片段列表 | 完整视频 (xfade/crossfade) | 全部 |
| V6. 字幕叠加 | 时间轴 + 文本 | ASS字幕文件 + 视频叠加 | 1-口播, 2-卡点, 5-故事 |
| V7. 定妆照生成 | 角色描述 | 角色多角度参考图 (Grid Method) | 5-故事, 6-模仿 ★ |
| V8. 图生视频 | 图片 + Prompt | 短视频片段 (Kling) | 5-故事, 6-模仿 ★ |
| V9. 首尾帧过渡 | 首帧 + 尾帧 + Prompt | 平滑过渡视频 | 5-故事 |
| V10. 语音驱动视频 | 图片 + 音频 | 对口型视频 (LipSync) | 1-口播, 3-数字人 |
### 3.3 动作迁移原子能力 ★ 新增
> 源自 Doubao 对话的 Animate 模型思路,也可使用现有 DreamActor
| 原子能力 | 输入 | 输出 | 被哪些路径使用 |
|:---------|:-----|:-----|:-------------|
| M1. 参考视频结构分析 | 视频文件 | 分段结构 + 镜头节奏 + 动作时间轴 | 6-模仿 |
| M2. 角色识别/分离 | 视频 + 角色标识 | 角色掩码/区域 | 6-模仿 |
| M3. 动作迁移 (2D/3D) | 参考视频 + 目标角色 | 目标角色复刻动作的视频 | 3-数字人(DreamActor), 6-模仿 |
| M4. 动作骨架提取 | 视频 | 2D/3D 骨骼关键帧序列 | 6-模仿 |
| M5. 背景替换/保留 | 视频 + 掩码 | 去除/保留背景后的视频 | 6-模仿 |
| M6. 视频结构模板化 | 视频 → 分镜模板 | 可复用的 {时间轴 + 切换点 + 动作标记} | 6-模仿 |
### 3.4 角色原子能力(跨线复用)
| 原子能力 | 输入 | 输出 | 被哪些路径使用 |
|:---------|:-----|:-----|:-------------|
| C1. 角色注册 | 角色描述 → registry | 角色 ID + 属性 + 参考图 | 全部 |
| C2. 角色锁定 | 剧本 + 角色 ID | 注入角色描述块 | 5-故事, 6-模仿 |
| C3. 角色一致性保持 | 角色参考图 + 场景 | 固定 seed + 定妆照 → 多场景生成 | 5-故事, 6-模仿 |
| C4. 角色替换 | 原视频 + 新角色 | 新角色 + 保留原动作/表情 | 6-模仿 |
### 3.5 通用基础能力
| 原子能力 | 说明 |
|:---------|:-----|
| G1. 配置加载 | config.yaml + local.yaml 合并 |
| G2. 成本追踪 | 每步操作计费 + API 调用计数 |
| G3. 进度报告 | 状态机 + Dashboard 埋点 |
| G4. 缓存管理 | 素材/模型/results 缓存 |
| G5. 错误容错 | 模块失败不阻塞整条链路 |
---
## 4. 各生产路径的完整链路
> 用 `[A1]` 标记引用底层原子能力;`[★]` 标记待完善/新增
### 4.1 口播线 (Oral)
```
当前: 文案 → [TTS] → [素材搜索] → [BGM] → [字幕] → [合成]
↓
优化后: 文案 → [TTS] → [素材搜索+V2 Ken Burns] → [AudioLine A4情绪分析选BGM]
→ [混音A8 人声+BGM] → [A10歌词对齐→字幕] → [V10 LipSync] → [合成]
```
**待完善**:
- [ ] 接入 AudioLine A4 情绪分析 → 自动匹配 BGM 风格
- [ ] 接入 AudioLine A8 混音 → 专业级人声+BGM 电平平衡
- [ ] V2 Ken Burns 效果 (已有 Pexels 素材多是静态风格需要)
### 4.2 卡点线 (Beat)
```
当前: BGM → [节拍检测] → [素材搜索] → [拍点切换] → [xfade]
↓
优化后: BGM → [AudioLine A2节拍检测] → [A5节奏生成(替换/增强鼓点)]
→ [V3节拍视觉对齐] → [V4变速卡点]
→ [A9分轨对齐(对齐到目标BPM)] → [合成]
```
**待完善**:
- [ ] 接入 AudioLine A5 节奏生成 → 在原BGM上叠加鼓点增强
- [ ] 接入 AudioLine A9 分轨对齐 → BPM 变更时各轨统一伸缩
- [ ] V3 节拍视觉对齐优化 → 当前只是简单切,需要更精确的拍点类型映射
### 4.3 数字人线 (Digital Human)
```
当前: 照片/视频 → [OmniHuman 对口型] / [DreamActor 动作模仿]
↓
优化后: 角色[C1注册] → 生成[C7定妆照] → [TTS] → [V10 LipSync]
→ 可选 [M3动作迁移] → [合成+背景]
```
**待完善**:
- [ ] 接入 C1 角色注册 → 数字人可被角色库管理
- [ ] 接入 Asset Manager → 素材复用
- [ ] DreamActor 轮询等待优化 → 当前 60 次循环硬编码
### 4.4 口播+卡点线 (Hybrid)
```
当前: 人声 + BGM → [变速卡点] → [帧锁定拼接]
↓
优化后: 人声 → [A1音轨分离] → 人声轨道锚点分析
+ BGM → [A2节拍检测] → BGM能量曲线
→ [V4变速卡点(人声锚点+BGM能量双驱动)]
→ [A8混音(人声+BGM电平平衡)]
→ [V3节拍对齐素材] → [合成]
```
**待完善**:
- [ ] 双驱动变速逻辑完善 (人声锚点优先级 > BGM 能量)
- [ ] 接入 AudioLine A8 混音
- [ ] 当前 `compose_hybrid` 需要和 beat_sync 统一底层
### 4.5 故事/漫剧线 (Story)
```
当前: 剧本 → [场景分解] → [桥接] → [Kling批量生成] → [角色拼接]
↓
优化后: 剧本 → [C2角色锁定] → [C7定妆照生成]
→ [场景分解(Multi-agent)] → [C3角色一致性保持]
→ [V8图生视频 + V9首尾帧过渡]
→ [AudioLine A6歌词创作 + A10对齐] # 如有旁白/主题曲
→ [V6字幕叠加] → [合成]
```
**待完善**:
- [ ] 角色锁定已实现 (character_locker.py) 但需要和 AudioLine 歌词输出配合
- [ ] 定妆照 → 多场景的角色一致性已部分实现,需扩展到模仿视频线
### 5. 新增:模仿视频线 (Action Transfer)
> 这是本方案的核心新增。整合了"卡点模仿" + "主人公更换"两个场景。
```
[参考视频] → Layer1 分析层 → Layer2 动作迁移 → Layer3 替换合成
```
#### 5.1 完整链路
```
输入: 参考视频 + (可选: 目标BGM / 目标角色 / 目标风格)
Step 1 — 参考视频分析 (M1 + A2 + A3)
├─ 视频结构分解: 镜头边界检测 → 分段标记
├─ 音频分析: [A2 BPM检测] + [A3结构解析] + [A4情绪分析]
├─ 视觉分析: [M4骨架提取] → 动作关键帧序列
└─ 节奏模板: 镜头切换时间轴 + 动作标记 + 节拍映射
Step 2 — 素材/角色准备
├─ 使用原视频素材 → 只替换角色 [M5背景保留 + C4角色替换]
├─ 使用新素材 → [V1素材搜索] + [V3节拍对齐]
└─ 使用原音频 → [A9分轨对齐到目标BPM]
Step 3 — 动作迁移 (M3)
├─ 路径A: DreamActor (火山引擎) → 高质量真人动作迁移
├─ 路径B: Animate模型 (ComfyUI+阿里) → 2D卡通动作迁移
└─ 路径C: Pose2Pose (轻量开源) → 简单动作映射
Step 4 — 合成输出
├─ 角色一致性保持 [C3]
├─ 替换音频: [A8混音] 新BGM/语音 + 原视频动作节奏
├─ [V5视频拼接] + [V6字幕叠加]
└─ [G3进度报告 + G2成本追踪]
```
#### 5.2 三个子场景映射
| 用户场景 | Step1 输入 | Step2 替换 | Step3 迁移方式 | Step4 输出 |
|:---------|:-----------|:-----------|:---------------|:-----------|
| **卡点模仿** | 参考视频 | 换BGM + 换素材 | 保留原动作节奏 | 新BGM+新素材的卡点视频 |
| **主人公更换** | 有角色视频 | 换角色 + 保留动作 | DreamActor/Animate | 同场景新角色 |
| **风格重制** | 参考视频 | 换BGM + 换角色 + 换风格 | 全链路 | 全新风格视频 |
#### 5.3 与现有能力的复用关系
```
模仿视频线 ← 已有 → 复用成熟度
├─ A2 BPM检测 → AudioLine ✅ 已实现
├─ A3结构解析 → AudioLine ✅ 已实现
├─ A9分轨对齐 → AudioLine ✅ 已实现
├─ A8混音 → AudioLine ✅ 已实现
├─ V1素材搜索 → pexels/search ✅ 已实现
├─ V3节拍视觉对齐 → beat_sync.py ✅ 已实现
├─ V5视频拼接 → ffmpeg.py ✅ 已实现
├─ V6字幕叠加 → ffmpeg.py ✅ 已实现
├─ C3角色一致性 → character_registry ✅ 部分实现
├─ M3动作迁移 → DreamActor ✅ 已有(DreamActor)
│ → Animate模型 ⚠️ 需接入(ComfyUI)
├─ M1参考视频分析 → ❌ 需新建
├─ M4骨架提取 → ❌ 需新建 (或集成 OpenPose)
├─ M5背景替换 → ❌ 需新建
└─ C4角色替换 → ❌ 需新建 (组合 M3 + M5 + C3)
```
---
## 6. 交叉能力矩阵
> 每个 ⬜ = 可用,⬛ = 核心依赖,↔ = 可选接入
| 原子能力 | 口播 | 卡点 | 数字人 | Hybrid | 故事 | 模仿 | 音频制造 |
|:---------|:----:|:----:|:------:|:------:|:----:|:----:|:--------:|
| **音频原子 (A1-A11)** | | | | | | | |
| A1 音轨分离 | ↔ | ⬛ | | ⬛ | | ⬛ | ⬛ |
| A2 BPM检测 | | ⬛ | | ⬛ | | ⬛ | ⬛ |
| A3 结构解析 | | ↔ | | | | ⬛ | ⬛ |
| A4 情绪分析 | ⬛ | ↔ | | ↔ | ⬛ | ⬛ | ⬛ |
| A5 节奏生成 | | ⬛ | | ↔ | | ↔ | ⬛ |
| A6 歌词创作 | ↔ | | | | ⬛ | | ⬛ |
| A7 旋律改编 | | | | | | | ⬛ |
| A8 混音母带 | ⬛ | ⬛ | ↔ | ⬛ | | ↔ | ⬛ |
| A9 分轨对齐 | | ⬛ | | ↔ | | ⬛ | ⬛ |
| A10 歌词对齐 | ⬛ | ↔ | | | ⬛ | | ↔ |
| A11 多格式导出 | ↔ | ↔ | ↔ | ↔ | ↔ | ↔ | ⬛ |
| **视觉原子 (V1-V10)** | | | | | | | |
| V1 素材搜索 | ⬛ | ⬛ | | ⬛ | ↔ | ⬛ | |
| V2 Ken Burns | ⬛ | | | | ⬛ | | |
| V3 节拍对齐 | | ⬛ | | ⬛ | | ⬛ | |
| V4 变速卡点 | | ⬛ | | ⬛ | | ↔ | |
| V5 视频拼接 | ⬛ | ⬛ | ⬛ | ⬛ | ⬛ | ⬛ | |
| V6 字幕叠加 | ⬛ | ↔ | | ↔ | ⬛ | ↔ | |
| V7 定妆照 | | | ↔ | | ⬛ | ⬛ | |
| V8 图生视频 | | | | | ⬛ | ↔ | |
| V9 首尾帧 | | | | | ⬛ | | |
| V10 LipSync | ⬛ | | ⬛ | ↔ | ⬛ | ↔ | |
| **动作原子 (M1-M6) ★** | | | | | | | |
| M1 视频结构分析 | | | | | | ⬛ | |
| M2 角色识别 | | | ↔ | | | ⬛ | |
| M3 动作迁移 | | | ⬛ | | | ⬛ | |
| M4 骨架提取 | | | | | | ⬛ | |
| M5 背景替换 | | | | | | ⬛ | |
| M6 模板化 | | | | | | ⬛ | |
| **角色原子 (C1-C4)** | | | | | | | |
| C1 角色注册 | ↔ | | ⬛ | | ⬛ | ⬛ | |
| C2 角色锁定 | | | | | ⬛ | ⬛ | |
| C3 一致性保持 | | | | | ⬛ | ⬛ | |
| C4 角色替换 | | | | | | ⬛ | |
---
## 7. 优先级与落地路线图
### Phase 1: 接入现有能力 (1-2天) ← 立即开始
| # | 任务 | 工作量 | 价值 |
|:-:|:-----|:------:|:----:|
| 1 | AudioLine 接入 AVE 主流程 (video_factory.py 调 AudioLine.run) | 2h | ⭐⭐⭐ |
| 2 | 口播线接入 AudioLine A4 情绪分析 → 自动选BGM | 1h | ⭐⭐ |
| 3 | 卡点线接入 AudioLine A9 分轨对齐 → 支持BPM变化 | 1h | ⭐⭐⭐ |
| 4 | 口播线接入 AudioLine A8 混音替代简单叠加 | 1h | ⭐⭐ |
| 5 | 角色注册接入数字人线 | 1h | ⭐ |
### Phase 2: 模仿视频线 MVP (3-5天)
| # | 任务 | 工作量 | 依赖 |
|:-:|:-----|:------:|:----:|
| 6 | M1 参考视频结构分析 (ffmpeg 场景检测 + A2节拍) | 4h | A2, V5 |
| 7 | M4 骨架提取集成 (OpenPose 轻量版) | 6h | - |
| 8 | M3 动作迁移路径C (Pose2Pose 轻量) | 4h | M4 |
| 9 | 模仿视频线编排器 (复用 orchestrator 模式) | 4h | M1, M3, A2 |
| 10 | CLI 接入: `video-factory --strategy 模仿` | 2h | #9 |
### Phase 3: 角色替换 + 高级动作迁移 (5-7天)
| # | 任务 | 工作量 | 依赖 |
|:-:|:-----|:------:|:----:|
| 11 | M5 背景替换 (RMBG/rembg) | 3h | - |
| 12 | C4 角色替换组合 (M3+M5+C3) | 4h | M3, M5 |
| 13 | M6 视频结构模板化 (保存/加载模板) | 3h | M1 |
| 14 | M3 路径A DreamActor 优化 (异步轮询改为 Webhook) | 2h | M3 |
| 15 | M3 路径B Animate 模型 ComfyUI 接入 | 8h | - |
### Phase 4: 优化 + 自动化 (持续)
| # | 任务 | 说明 |
|:-:|:-----|:------|
| 16 | 原子操作 CLI 化 → 每个原子能力可独立调用 | |
| 17 | Dashboard 接入模仿线 + 动作线数据 | |
| 18 | 素材缓存 + 结果缓存 (避免重复 API 调用) | |
| 19 | 批量处理模式 (一次性处理多个参考视频) | |
---
## 8. 自审与可行性分析
### 8.1 架构合理性审查
| 审查维度 | 评分 | 说明 |
|:---------|:----:|:-----|
| 模块内聚性 | ⭐⭐⭐⭐ | 每条生产路径独立,底层能力原子化 |
| 耦合度控制 | ⭐⭐⭐⭐ | 上层通过 orchestrator 调用下层,不跨层调用 |
| 可扩展性 | ⭐⭐⭐⭐⭐ | 新增生产路径 = 新编排器 + 复用底层原子 |
| 测试可行性 | ⭐⭐⭐ | 原子能力可单独测试,但端到端链路依赖API |
| 容错能力 | ⭐⭐⭐ | 模块级容错已实现,但动作迁移线无回退方案 |
### 8.2 具体落地问题
#### 问题1: DreamActor 视频轮询耗时太长
- **现状**: 60×10s = 10 分钟轮询,阻塞整条链路
- **建议**: 改为异步任务,提交流程后立即返回 task_id,后台 Dashboard 显示进度
#### 问题2: Animate 模型依赖 ComfyUI
- **现状**: ComfyUI 非本地部署,需额外启动
- **建议**: Phase 2 先用 DreamActor 跑通 MVP,ComfyUI 接入作为 Phase 3 优化项
#### 问题3: 骨架提取 (M4) 精度
- **现状**: OpenPose 需要 GPU,轻量版(MediaPipe Pose)精度有限
- **建议**:
- 路径A: 用 MediaPipe (免费,精准度70%)
- 路径B: 用阿里 Animate 内置骨骼提取 (精准度90%,需 ComfyUI)
- 路径C: 跳过骨架提取,直接用原始视频做帧匹配 (最简单)
#### 问题4: AudioLine 依赖 oMLX + Suno + Mubert
- **现状**: oMLX 需手动启动,Suno/Mubert 需 API key
- **建议**:
- 无 API key 时模块自动回退到本地方案 (已实现)
- oMLX 启动自动化加入 launchd 或 Start Server 脚本
#### 问题5: 角色替换的"合理性"问题
- **现状**: 替换角色后,原视频中的人脸/手部动作可能与新角色不匹配
- **建议**:
- 2D 卡通角色替换比真人替换更容易获得合理结果
- 真人替换建议用 DreamActor(原生支持面部替换)
- 首次测试建议用卡通角色 + 卡点场景,不要一步上真人
### 8.3 是否有更优解?
| 现有方案 | 更优解 | 评估 |
|:---------|:-------|:-----|
| 每条线独立编排器 | **统一编排器 + 插件模式** | 更优但重构成本高,建议 Phase 4 |
| DreamActor 轮询 | **Webhook 回调** | 更优,但需服务端支持,暂不可行 |
| OpenPose 本地骨架 | **阿里 Animate 内置骨骼** | 更优,但需接入 ComfyUI |
| 手动 API Key 配置 | **环境变量 + .env 文件** | 更优,建议 Phase 1 顺便完成 |
| stand-alone AudioLine | **AudioLine as AVE plugin** | 已在设计范围内 |
### 8.4 风险矩阵
| 风险 | 概率 | 影响 | 缓解措施 |
|:----|:----:|:----:|:---------|
| API 限频/涨价 | 中 | 高 | 本地回退 + 成本预警 |
| 动作迁移质量不达标 | 中 | 高 | Phase 2 MVP 先用简单场景验证 |
| ComfyUI 部署复杂 | 高 | 中 | 先用 DreamActor,ComfyUI 后接 |
| 角色替换效果假 | 中 | 中 | 用卡通角色测试,真人暂缓 |
| AudioLine 依赖未启动 | 低 | 中 | oMLX 自动启动 + 模块自检告警 |
---
## 9. 具体落地建议
### 9.1 立即执行 (今天/明天)
```bash
# Step 1: AudioLine 接入 AVE 主流程
# 在 video_factory.py 中新增:
# from audio_line import AudioLine
# al = AudioLine()
#
# # 口播线: 用 AudioLine 情绪分析选BGM
# emotion = al.emotion_analyzer.analyze(voice_path)
# bgm_style = emotion_to_bgm(emotion.mood)
#
# # 卡点线: 用 AudioLine BPM分析 + 分轨对齐
# beat = al.beat_detector.detect(bgm_path)
# aligned_stems = al.stem_aligner.align_to_bpm(...)
```
### 9.2 本周内
```
1. 模仿视频线 MVP:
- 输入: 参考视频
- 输出: 提取的结构模板 + 骨架序列
- 不生成最终视频,只验证分析层
2. 角色系统统一:
- 数字人注册到 CharacterRegistry
- 口播脚本支持引用角色
```
### 9.3 需要注意的坑
1. **不要一步到位**:先跑通 MVP 再迭代优化
2. **每个原子能力先单测再集成**:特别是 M1-M6 动作迁移线
3. **AudioLine 已经写好,但 demo/ 里还没跑的端到端测试**:先跑通一个卡点场景的端到端
4. **模仿视频线的骨架提取不要依赖 GPU**:用 MediaPipe 或完全跳过骨架,直接用帧匹配
---
> 本方案已包含自审环节 (Section 8),所有建议均有明确的落地路径。
> 下一步建议: 确认方案方向 → Phase 1 将 AudioLine 接入 AVE → Phase 2 开始模仿视频线 MVP
@@ -0,0 +1,69 @@
"""
批量生成 8 张漫剧分镜图 — WorkBuddy 云服务 (混元生图 3.0)
"""
import sys, json, os, subprocess, time
OUT_DIR = "/Users/7kecheng/workbuddy-agent-os/agent-local/tools/ave/cache/generated/PROD-20260517-0003"
os.makedirs(OUT_DIR, exist_ok=True)
CLOUD_SCRIPT = "/Applications/WorkBuddy.app/Contents/Resources/app.asar.unpacked/resources/builtin-skills/buddy-multimodal-generation/scripts/buddy-cloud.py"
PYTHON = "/Users/7kecheng/.workbuddy/binaries/python/envs/agent-os/bin/python"
# 从 visual_prompts JSON 加载
with open("/Users/7kecheng/workbuddy-agent-os/agent-local/tools/ave/cache/PROD-20260517-0003_visual_prompts.json") as f:
prompts = json.load(f)
# 读取 token
token = open("/dev/tty", "r") if False else "" # placeholder — will pass via pipe
print(f"将生成 {len(prompts)} 张分镜图")
print()
results = []
for i, vp in enumerate(prompts):
seg_id = vp["id"]
scene = vp["scene"]
prompt = vp["visual_prompt"]
# 清理 prompt — 去掉架构标记,转为纯文本描述
clean_prompt = (
f"日系清新漫画风格。{scene}。"
f"主角阿远,黑短发戴细框眼镜,"
f"情绪:{vp['emotion']}。"
f"镜头:{vp.get('reference_image', '中景')}"
)
# 使用更精确的 prompt
final_prompt = (
f"日系清新漫画风格,{scene}。"
f"一个25岁黑短发戴细框金属眼镜的偏瘦男生阿远,"
f"表情{vp['emotion']}。"
f"柔和色调,线条干净,温暖治愈氛围。"
)
out_path = f"{OUT_DIR}/seg_{seg_id:02d}_{scene}.png"
print(f"[{i+1}/{len(prompts)}] #{seg_id} {scene} — 生成中...", end=" ", flush=True)
start = time.time()
# 使用 --no-poll 先提交,后续再检查状态
# 先试试直接同步生成
result = subprocess.run(
[PYTHON, CLOUD_SCRIPT, "image", final_prompt,
"--resolution", "1248:832",
"--revise", "1",
"--token-stdin"],
input=token,
capture_output=True, text=True, timeout=300
)
elapsed = time.time() - start
if result.returncode == 0:
# 从输出中提取图片 URL
output = result.stdout.strip()
print(f"✅ ({elapsed:.0f}s)")
# 保存输出信息
results.append({"id": seg_id, "scene": scene, "output": output, "path": out_path})
else:
print(f"❌ 失败: {result.stderr.strip()[:100]}")
results.append({"id": seg_id, "scene": scene, "error": result.stderr.strip()})
print(f"\n完成: {len([r for r in results if 'output' in r])}/{len(prompts)} 张")
@@ -0,0 +1,155 @@
"""
AVE 04_anchor_extractor — 双轨锚点提取
职责:
1. 人声锚点: 检测人声活跃段(word-level timestamps)
2. BGM 锚点: 检测节奏/情绪变化点(section boundaries)
3. 输出 anchors.json: 供 composer 做画面切换时间参考
依赖:
- librosa (本地 CPU 即可)
- numpy
"""
import sys, os; sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import json
import os
from pathlib import Path
from typing import Any
import numpy as np
from lib.logger import get_logger
logger = get_logger("anchor_extractor")
def extract_anchors(
voice_path: str,
bgm_path: str | None = None,
output_path: str = "anchors.json",
) -> dict[str, Any]:
"""
双轨锚点提取: 人声 + BGM → anchors.json
- voice_path: 人声 WAV 路径
- bgm_path: BGM 路径 (可选)
- 返回: anchors dict
anchors 格式:
{
"voice_anchors": [
{"time": 1.2, "type": "word_start", "word": "今天"},
{"time": 3.5, "type": "word_end", "word": "今天"},
{"time": 5.0, "type": "silence", "duration": 0.3},
],
"bgm_anchors": [
{"time": 0.0, "section": "intro"},
{"time": 8.0, "section": "main"},
{"time": 50.0, "section": "climax"},
],
"metadata": {
"voice_duration_sec": 75.0,
"bgm_duration_sec": 75.0,
"sample_rate": 24000
}
}
"""
import librosa
anchors: dict[str, Any] = {
"voice_anchors": [],
"bgm_anchors": [],
"metadata": {},
}
# ── 1. 人声锚点 ──
if not os.path.exists(voice_path):
raise FileNotFoundError(f"人声文件不存在: {voice_path}")
y, sr = librosa.load(voice_path, sr=24000, mono=True)
duration = librosa.get_duration(y=y, sr=sr)
anchors["metadata"]["voice_duration_sec"] = duration
anchors["metadata"]["sample_rate"] = sr
# 语音活动检测: 能量阈值法
frame_length = int(sr * 0.025) # 25ms 帧
hop_length = int(sr * 0.010) # 10ms 步进
energy = librosa.feature.rms(y=y, frame_length=frame_length, hop_length=hop_length)[0]
threshold = np.mean(energy) * 0.3
# 检测活跃段
is_active = energy > threshold
voice_anchors = []
current_start = None
for i, active in enumerate(is_active):
time_sec = i * hop_length / sr
if active and current_start is None:
current_start = time_sec
voice_anchors.append({"time": round(time_sec, 2), "type": "voice_start"})
elif not active and current_start is not None:
voice_anchors.append({"time": round(time_sec, 2), "type": "voice_end"})
current_start = None
if current_start is not None:
voice_anchors.append({"time": round(duration, 2), "type": "voice_end"})
anchors["voice_anchors"] = voice_anchors
logger.info(f"人声锚点: {len(voice_anchors)} 个")
# ── 2. BGM 锚点 (节奏变化检测) ──
if bgm_path and os.path.exists(bgm_path):
y_bgm, sr_bgm = librosa.load(bgm_path, sr=22050, mono=True)
bgm_duration = librosa.get_duration(y=y_bgm, sr=sr_bgm)
anchors["metadata"]["bgm_duration_sec"] = bgm_duration
# 节奏检测 (tempo)
tempo, beats = librosa.beat.beat_track(y=y_bgm, sr=sr_bgm)
beat_times = librosa.frames_to_time(beats, sr=sr_bgm)
bgm_anchors = [{"time": round(t, 2), "type": "beat"} for t in beat_times]
anchors["bgm_anchors"] = bgm_anchors
anchors["metadata"]["bgm_tempo"] = round(float(tempo), 1)
logger.info(f"BGM 节奏: {float(tempo):.1f} BPM, {len(bgm_anchors)} 个拍点")
else:
logger.info("无 BGM 文件,跳过 BGM 锚点")
# ── 写入文件 ──
with open(output_path, "w", encoding="utf-8") as f:
json.dump(anchors, f, ensure_ascii=False, indent=2)
logger.info(f"锚点已写入: {output_path}")
return anchors
def get_silence_periods(
voice_path: str,
min_silence_sec: float = 0.5,
) -> list[dict]:
"""
检测人声中的静音段落 (可用于画面过渡时机)
返回: [{"start": 1.0, "end": 1.5, "duration": 0.5}, ...]
"""
import librosa
y, sr = librosa.load(voice_path, sr=24000, mono=True)
intervals = librosa.effects.split(
y, top_db=30, frame_length=int(sr * 0.025), hop_length=int(sr * 0.010)
)
# intervals 是语音段,间隙就是静音段
silence_periods = []
prev_end = 0
for start, end in intervals:
gap = start / sr - prev_end
if gap >= min_silence_sec:
silence_periods.append({
"start": round(prev_end, 2),
"end": round(start / sr, 2),
"duration": round(gap, 2),
})
prev_end = end / sr
return silence_periods
@@ -0,0 +1,14 @@
"""
AVE asset_manager — 素材资产管理包
提供素材索引、缓存管理、标签搜索三大能力。
模块:
index.py — AssetIndex: 扫描目录、提取元数据、入库 SQLite
cache.py — CacheManager: 增量扫描、缓存清理、孤立文件回收
tags.py — AssetSearch: 自动标签、模糊搜索、CLI 查询
依赖:
- lib/dashboard.py (assets 表 schema 已存在)
- ffprobe (FFmpeg, 用于提取视频元数据)
"""
@@ -0,0 +1,367 @@
"""
asset_manager/cache — 缓存管理与自动入库
职责:
1. 增量扫描: 跟踪目录 mtime, 只扫描新文件
2. 自动入库: 发现新文件 → 调用 AssetIndex 入库
3. 缓存清理: 按策略清理孤立/过期文件
4. 生产者钩子: Pexels/Kling 下载完成后自动通知
用法:
from asset_manager.cache import CacheManager
cm = CacheManager()
cm.scan_new() # 扫描所有目录的新文件
cm.cleanup(days=30) # 清理 30 天前无关联的素材
cm.register_scan("/tmp/") # 注册额外扫描目录
设计原则:
- 无持续进程/守护: 每次 CLI 调用时触发扫描
- mtime 记录在 SQLite meta_json 中, 跨会话持久
- 重要素材 (有 production_id 关联) 不会被清理
"""
import json
import os
import shutil
import sqlite3
import time
from datetime import datetime, timedelta
from pathlib import Path
from typing import Callable, Optional, Union
from lib.logger import get_logger
from asset_manager.index import AssetIndex
logger = get_logger("asset_cache")
# ── 全局路径 (复用 index.py) ─────────────────────────────
_LOCAL_ROOT = Path(os.environ.get(
"AVE_LOCAL_DIR",
str(Path.home() / "workbuddy-agent-os/agent-local"),
))
_CACHE_DIR = _LOCAL_ROOT / "tools" / "ave" / "cache"
_DB_PATH = _LOCAL_ROOT / "tools" / "ave" / "data" / "ave.db"
# ═══════════════════════════════════════════════════════════
# 核心类
# ═══════════════════════════════════════════════════════════
class CacheManager:
"""缓存管理器
属性:
index: AssetIndex 实例 (共享 DB)
scan_records: {dir_path: last_mtime} 记录上次扫描时间
"""
def __init__(self, cache_dir: Union[str, Path] = _CACHE_DIR,
db_path: Union[str, Path] = _DB_PATH):
self.cache_dir = Path(cache_dir)
self.db_path = Path(db_path)
self.index = AssetIndex(cache_dir=cache_dir, db_path=db_path)
# 额外扫描目录注册表: {路径: 回调函数}
self._extra_scans: list[tuple[str, Optional[Callable]]] = []
# ── 注册额外扫描目录 ─────────────────────────────────
def register_scan(self, directory: str,
callback: Optional[Callable] = None):
"""注册额外目录到扫描列表 (如外部导入的素材目录)"""
self._extra_scans.append((directory, callback))
logger.info(f"注册扫描目录: {directory}")
# ── 增量扫描 ─────────────────────────────────────────
def _load_scan_record(self, subdir: str) -> float:
"""从 meta_json 读取上次扫描记录的 mtime"""
conn = sqlite3.connect(str(self.db_path))
try:
# 用特殊 hash 值 '_scan_record' 标记扫描记录
row = conn.execute(
"SELECT meta_json FROM assets WHERE hash = ? AND source = ?",
(f"_scan_{subdir}", "__cache_mgr__"),
).fetchone()
if row:
rec = json.loads(row[0])
return float(rec.get("last_mtime", 0))
except Exception:
pass
finally:
conn.close()
return 0.0
def _save_scan_record(self, subdir: str, mtime: float):
"""持久化扫描记录到 DB"""
conn = sqlite3.connect(str(self.db_path))
try:
# 先删除旧记录
conn.execute(
"DELETE FROM assets WHERE hash = ? AND source = ?",
(f"_scan_{subdir}", "__cache_mgr__"),
)
conn.execute(
"""INSERT INTO assets (asset_type, name, file_path, file_size, hash, source, meta_json)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
("__meta__", f"scan_record_{subdir}", "",
0, f"_scan_{subdir}", "__cache_mgr__",
json.dumps({"last_mtime": mtime, "updated_at": datetime.now().isoformat()})),
)
conn.commit()
except Exception as e:
logger.debug(f"保存扫描记录失败: {e}")
finally:
conn.close()
def scan_new(self, force_rescan: bool = False) -> dict:
"""
增量扫描: 只扫描 mtime 超过上次记录的文件。
参数:
force_rescan: 强制全量重新扫描
返回:
{"total_new": int, "per_dir": {subdir: new_count}}
"""
from asset_manager.index import WATCHED_SUBDIRS, EXT_TYPE_MAP
results: dict = {"total_new": 0, "per_dir": {}}
valid_exts = set(EXT_TYPE_MAP.keys())
for subdir in WATCHED_SUBDIRS:
target = self.cache_dir / subdir
if not target.is_dir():
continue
last_mtime = 0.0 if force_rescan else self._load_scan_record(subdir)
new_count = 0
for fpath in sorted(target.rglob("*")):
if not fpath.is_file():
continue
if fpath.suffix.lower() not in valid_exts:
continue
file_mtime = fpath.stat().st_mtime
if file_mtime > last_mtime:
ret = self.index.index_file(str(fpath))
if ret > 0:
new_count += 1
# 更新扫描记录 (用当前最大 mtime)
current_max = 0.0
for fpath in target.rglob("*"):
if fpath.is_file():
current_max = max(current_max, fpath.stat().st_mtime)
self._save_scan_record(subdir, current_max)
if new_count > 0:
logger.info(f" [{subdir}] 新增 {new_count} 个素材")
results["per_dir"][subdir] = new_count
results["total_new"] += new_count
# 额外扫描目录
for extra_dir, callback in self._extra_scans:
extra_path = Path(extra_dir)
if not extra_path.is_dir():
continue
for fpath in extra_path.rglob("*"):
if not fpath.is_file():
continue
if fpath.suffix.lower() not in valid_exts:
continue
ret = self.index.index_file(str(fpath))
if ret > 0:
results["total_new"] += 1
if callback:
try:
callback(str(fpath))
except Exception as e:
logger.warning(f"回调失败 {fpath}: {e}")
logger.info(f"增量扫描完成, 共新增 {results['total_new']} 个素材")
return results
# ── 全量扫描 ─────────────────────────────────────────
def scan_all(self, production_id: int = 0) -> dict:
"""全量扫描并重置所有扫描记录"""
results = self.index.scan_all(production_id)
# 重置扫描记录为最新 mtime
from asset_manager.index import WATCHED_SUBDIRS
for subdir in WATCHED_SUBDIRS:
target = self.cache_dir / subdir
if target.is_dir():
current_max = max(
(f.stat().st_mtime for f in target.rglob("*") if f.is_file()),
default=0.0,
)
self._save_scan_record(subdir, current_max)
return results
# ── 缓存清理 ─────────────────────────────────────────
def cleanup(self, days: int = 30, dry_run: bool = False) -> dict:
"""
清理过期缓存。
清理规则:
- 无 production_id 关联
- 文件创建时间 > days 天前
- 保留有 production 引用的素材
参数:
days: 保留天数
dry_run: 仅预览不删除
返回:
{"deleted": int, "freed_bytes": int, "files": [路径]}
"""
conn = sqlite3.connect(str(self.db_path))
cutoff = (datetime.now() - timedelta(days=days)).isoformat()
result: dict = {"deleted": 0, "freed_bytes": 0, "files": []}
try:
# 查找无关联的旧资产
rows = conn.execute(
"""SELECT id, file_path, file_size FROM assets
WHERE production_id IS NULL
AND created_at < ?
AND asset_type != '__meta__'
ORDER BY created_at ASC""",
(cutoff,),
).fetchall()
for row in rows:
aid, fpath, fsize = row["id"], row["file_path"], row["file_size"] or 0
if dry_run:
result["deleted"] += 1
result["freed_bytes"] += fsize
result["files"].append(f"{fpath} ({_human_size(fsize)})")
continue
# 删除磁盘文件
if fpath and os.path.isfile(fpath):
try:
os.remove(fpath)
logger.debug(f" 删除文件: {fpath}")
except OSError as e:
logger.warning(f" 删除失败: {fpath}: {e}")
# 删除 DB 记录
conn.execute("DELETE FROM asset_tags WHERE asset_id = ?", (aid,))
conn.execute("DELETE FROM production_assets WHERE asset_id = ?", (aid,))
conn.execute("DELETE FROM assets WHERE id = ?", (aid,))
result["deleted"] += 1
result["freed_bytes"] += fsize
result["files"].append(fpath)
conn.commit()
except Exception as e:
logger.error(f"cleanup 失败: {e}")
finally:
conn.close()
action = "预览" if dry_run else "清理"
logger.info(f"缓存{action}: {result['deleted']} 个文件, "
f"释放 {_human_size(result['freed_bytes'])}")
return result
# ── 目录统计 ─────────────────────────────────────────
def disk_usage(self) -> dict:
"""统计每个缓存目录的磁盘占用"""
usage: dict = {}
for subdir in os.listdir(self.cache_dir):
target = self.cache_dir / subdir
if not target.is_dir():
continue
total = 0
count = 0
for fpath in target.rglob("*"):
if fpath.is_file():
total += fpath.stat().st_size
count += 1
usage[subdir] = {
"count": count,
"size_bytes": total,
"size_human": _human_size(total),
}
return usage
def print_disk_usage(self):
"""打印磁盘占用表"""
usage = self.disk_usage()
print(f"\n{'='*50}")
print(f" 缓存磁盘占用")
print(f"{'='*50}")
print(f" {'目录':20s} {'文件数':>8s} {'大小':>12s}")
print(f" {'-'*40}")
total_size = 0
for subdir, info in sorted(usage.items(), key=lambda x: -x[1]["size_bytes"]):
print(f" {subdir:20s} {info['count']:>8d} {info['size_human']:>12s}")
total_size += info["size_bytes"]
print(f" {'-'*40}")
print(f" {'总计':20s} {'':>8s} {_human_size(total_size):>12s}")
print(f"{'='*50}\n")
# ═══════════════════════════════════════════════════════════
# 生产者自动入库钩子
# ═══════════════════════════════════════════════════════════
def auto_index_on_download(file_path: str, production_id: int = 0) -> int:
"""
生产者在下载/生成素材后, 调用此函数自动入库。
用法:
from asset_manager.cache import auto_index_on_download
path = pexels_search.download(url)
auto_index_on_download(path, production_id=pid)
"""
idx = AssetIndex()
return idx.index_file(file_path, production_id=production_id)
def index_output_video(output_path: str, production_id: int) -> int:
"""
最终成片入库 (特殊标记 source=output)。
"""
if not os.path.isfile(output_path):
return -1
idx = AssetIndex()
# 强制标记 source 为 output
conn = sqlite3.connect(str(idx.db_path))
try:
# 先通过 index_file 入库
aid = idx.index_file(output_path, production_id=production_id)
if aid > 0:
# 覆盖 source
conn.execute(
"UPDATE assets SET source = 'production_output' WHERE id = ?",
(aid,),
)
conn.commit()
return aid
return aid
except Exception as e:
logger.error(f"index_output 失败: {e}")
return -1
finally:
conn.close()
# ── 辅助函数 ─────────────────────────────────────────────
def _human_size(bytes_: int) -> str:
if bytes_ < 1024:
return f"{bytes_} B"
elif bytes_ < 1024**2:
return f"{bytes_/1024:.1f} KB"
elif bytes_ < 1024**3:
return f"{bytes_/(1024**2):.1f} MB"
return f"{bytes_/(1024**3):.2f} GB"
@@ -0,0 +1,469 @@
"""
asset_manager/index — 素材索引引擎
职责:
1. 扫描所有已知素材目录,发现新文件
2. 通过 ffprobe 提取视频/音频元数据 (时长/分辨率/编码/FPS)
3. 计算文件 hash (首 64KB + 文件大小, 快速去重)
4. 写入 SQLite assets 表, 避免重复入库
用法:
from asset_manager.index import AssetIndex
idx = AssetIndex()
stats = idx.scan_all() # 全量扫描所有目录
stats = idx.scan_dir("materials") # 扫描单个子目录
print(idx.summarize()) # 打印统计
AssetIndex 发现策略:
- 只扫描已知目录 (materials/kling/wan2_2/bgm/bgm_library/lipsync/character_sheet)
- 按 hash 去重: 同一文件即使出现在不同目录也只入库一次
- 增量模式: 只扫描 mtime > 上次扫描时间的文件
"""
import hashlib
import json
import os
import sqlite3
import subprocess
import time
from datetime import datetime
from pathlib import Path
from typing import Optional, Union
from lib.logger import get_logger
logger = get_logger("asset_index")
# ── 全局路径 ───────────────────────────────────────────────
_LOCAL_ROOT = Path(os.environ.get(
"AVE_LOCAL_DIR",
str(Path.home() / "workbuddy-agent-os/agent-local"),
))
_CACHE_DIR = _LOCAL_ROOT / "tools" / "ave" / "cache"
_DB_PATH = _LOCAL_ROOT / "tools" / "ave" / "data" / "ave.db"
# ── 已知素材目录 (相对于 cache) ──────────────────────────
WATCHED_SUBDIRS: dict[str, str] = {
"materials": "pexels 下载素材",
"kling": "Kling 生成视频",
"wan2_2": "Wan2.2 数字人视频",
"bgm": "BGM 缓存",
"bgm_library": "BGM 素材库",
"lipsync": "唇形同步产出",
"character_sheet": "定妆照/角色图",
"outputs": "最终成片",
"beat_sync": "节拍分析缓存",
}
# ── 已知扩展名 → asset_type ─────────────────────────────
EXT_TYPE_MAP = {
".mp4": "video",
".mov": "video",
".webm": "video",
".avi": "video",
".mkv": "video",
".wav": "audio",
".mp3": "audio",
".aac": "audio",
".m4a": "audio",
".flac": "audio",
".ogg": "audio",
".png": "image",
".jpg": "image",
".jpeg": "image",
".webp": "image",
".gif": "image",
}
# ═══════════════════════════════════════════════════════════
# 核心类
# ═══════════════════════════════════════════════════════════
class AssetIndex:
"""素材索引引擎"""
def __init__(self, cache_dir: Union[str, Path] = _CACHE_DIR,
db_path: Union[str, Path] = _DB_PATH):
self.cache_dir = Path(cache_dir)
self.db_path = Path(db_path)
self._init_db()
# ── DB 初始化 ──────────────────────────────────────────
def _init_db(self):
"""确保 assets 相关表存在(复用 dashboard.py 的 schema)"""
try:
conn = sqlite3.connect(str(self.db_path))
conn.executescript("""
CREATE TABLE IF NOT EXISTS assets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
asset_type TEXT NOT NULL,
name TEXT,
file_path TEXT,
file_size INTEGER,
hash TEXT,
source TEXT,
meta_json TEXT,
production_id INTEGER,
created_at TEXT DEFAULT (datetime('now','localtime'))
);
CREATE TABLE IF NOT EXISTS asset_tags (
asset_id INTEGER NOT NULL REFERENCES assets(id),
tag TEXT NOT NULL,
PRIMARY KEY (asset_id, tag)
);
CREATE TABLE IF NOT EXISTS production_assets (
production_id INTEGER NOT NULL REFERENCES productions(id),
asset_id INTEGER NOT NULL REFERENCES assets(id),
role TEXT,
PRIMARY KEY (production_id, asset_id)
);
CREATE INDEX IF NOT EXISTS idx_assets_hash ON assets(hash);
CREATE INDEX IF NOT EXISTS idx_assets_type ON assets(asset_type);
CREATE INDEX IF NOT EXISTS idx_assets_path ON assets(file_path);
CREATE INDEX IF NOT EXISTS idx_asset_tags_tag ON asset_tags(tag);
""")
conn.commit()
conn.close()
except Exception as e:
logger.error(f"DB 初始化失败: {e}")
# ── 文件级操作 ────────────────────────────────────────
def _compute_hash(self, file_path: str) -> str:
"""快速 hash: 读首 64KB + 文件大小 (比全量 MD5 快 100x)"""
try:
sz = os.path.getsize(file_path)
with open(file_path, "rb") as f:
head = f.read(65536) # 64KB
h = hashlib.md5(head).hexdigest()[:16]
return f"{h}_{sz}"
except OSError:
return ""
def _extract_media_info(self, file_path: str) -> dict:
"""通过 ffprobe 提取媒体元数据"""
meta: dict = {}
ext = Path(file_path).suffix.lower()
# 非视频/音频文件跳过 ffprobe
if ext not in (".mp4", ".mov", ".webm", ".avi", ".mkv",
".wav", ".mp3", ".aac", ".m4a", ".flac", ".ogg"):
return meta
try:
result = subprocess.run(
["ffprobe", "-v", "quiet", "-print_format", "json",
"-show_format", "-show_streams", file_path],
capture_output=True, text=True, timeout=30,
)
if result.returncode != 0:
return meta
data = json.loads(result.stdout)
# 格式信息
fmt = data.get("format", {})
meta["format"] = fmt.get("format_name", "")
meta["bitrate"] = int(fmt.get("bit_rate", 0))
meta["duration_sec"] = round(float(fmt.get("duration", 0)), 2)
meta["size_bytes"] = int(fmt.get("size", 0))
# 视频流
for stream in data.get("streams", []):
if stream.get("codec_type") == "video":
meta["video_codec"] = stream.get("codec_name", "")
meta["width"] = stream.get("width", 0)
meta["height"] = stream.get("height", 0)
meta["fps"] = _parse_fps(stream.get("r_frame_rate", ""))
meta["pixel_format"] = stream.get("pix_fmt", "")
break
# 音频流
for stream in data.get("streams", []):
if stream.get("codec_type") == "audio":
meta["audio_codec"] = stream.get("codec_name", "")
meta["sample_rate"] = int(stream.get("sample_rate", 0))
meta["channels"] = stream.get("channels", 0)
break
except (subprocess.TimeoutExpired, json.JSONDecodeError,
FileNotFoundError) as e:
logger.debug(f"ffprobe 失败 {file_path}: {e}")
return meta
def _source_from_path(self, file_path: str) -> str:
"""从文件路径推断来源"""
p = Path(file_path)
try:
rel = p.relative_to(self.cache_dir)
parts = rel.parts
if len(parts) >= 1:
return parts[0] # 顶级子目录名 = source
except ValueError:
pass
return "unknown"
def _infer_tags(self, file_path: str, meta: dict, source: str) -> list[str]:
"""基于文件属性自动生成标签"""
tags: list[str] = [source]
ext = Path(file_path).suffix.lower()
# 文件类型标签
asset_type = EXT_TYPE_MAP.get(ext, "other")
tags.append(asset_type)
# 尺寸标签 (视频)
w = meta.get("width", 0)
h = meta.get("height", 0)
if w and h:
if w >= 3840 or h >= 2160:
tags.append("4k")
elif w >= 1920 or h >= 1080:
tags.append("1080p")
elif w >= 1280 or h >= 720:
tags.append("720p")
else:
tags.append("sd")
# 时长标签
dur = meta.get("duration_sec", 0)
if dur > 0:
if dur < 2:
tags.append("ultra_short")
elif dur < 5:
tags.append("short")
elif dur < 15:
tags.append("medium")
elif dur < 60:
tags.append("long")
else:
tags.append("extra_long")
# 编码标签
vcodec = meta.get("video_codec", "")
if vcodec:
tags.append(f"codec_{vcodec}")
return tags
# ── 单文件入库 ────────────────────────────────────────
def index_file(self, file_path: str, production_id: int = 0,
force: bool = False) -> int:
"""
索引单个文件并入库。
参数:
file_path: 文件绝对路径
production_id: 关联的 production ID (0 = 不关联)
force: 强制重新索引 (即使 hash 已存在)
返回:
asset_id (失败返回 -1, 已存在且未 force 返回 -2)
"""
if not os.path.isfile(file_path):
logger.warning(f"文件不存在: {file_path}")
return -1
ext = Path(file_path).suffix.lower()
asset_type = EXT_TYPE_MAP.get(ext, "other")
name = Path(file_path).name
file_size = os.path.getsize(file_path)
file_hash = self._compute_hash(file_path)
source = self._source_from_path(file_path)
meta = self._extract_media_info(file_path)
tags = self._infer_tags(file_path, meta, source)
if not file_hash:
return -1
conn = sqlite3.connect(str(self.db_path))
try:
# 去重检查
if not force and file_hash:
existing = conn.execute(
"SELECT id FROM assets WHERE hash = ? LIMIT 1",
(file_hash,),
).fetchone()
if existing:
conn.close()
return -2 # 已存在
# 插入资产
cur = conn.execute(
"""INSERT INTO assets
(asset_type, name, file_path, file_size, hash, source, meta_json, production_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(asset_type, name, file_path, file_size, file_hash, source,
json.dumps(meta, ensure_ascii=False),
production_id if production_id > 0 else None),
)
aid = cur.lastrowid
# 标签
for tag in set(tags): # set 去重
conn.execute(
"INSERT OR IGNORE INTO asset_tags (asset_id, tag) VALUES (?, ?)",
(aid, tag),
)
# 关联 production
if production_id > 0:
conn.execute(
"""INSERT OR IGNORE INTO production_assets
(production_id, asset_id, role) VALUES (?, ?, ?)""",
(production_id, aid, "output" if source in ("materials", "kling", "wan2_2")
else "cache"),
)
conn.commit()
logger.info(f" 入库: {name} (type={asset_type}, hash={file_hash[:12]}...)")
return aid
except Exception as e:
logger.error(f"index_file 失败: {file_path}: {e}")
return -1
finally:
conn.close()
# ── 目录扫描 ──────────────────────────────────────────
def scan_dir(self, subdir: str, production_id: int = 0,
progress: bool = True) -> dict:
"""
扫描一个子目录, 入库所有新文件。
参数:
subdir: WATCHED_SUBDIRS 中的 key (如 "materials")
production_id: 可选关联 production
progress: 打印进度
返回:
{"dir": str, "total": int, "new": int, "skipped": int, "errors": int}
"""
target_dir = self.cache_dir / subdir
stat = {"dir": subdir, "total": 0, "new": 0, "skipped": 0, "errors": 0}
if not target_dir.is_dir():
logger.warning(f"目录不存在: {target_dir}")
return stat
files = sorted(target_dir.rglob("*"))
valid_exts = set(EXT_TYPE_MAP.keys())
for i, fpath in enumerate(files):
if not fpath.is_file():
continue
if fpath.suffix.lower() not in valid_exts:
continue
stat["total"] += 1
ret = self.index_file(str(fpath), production_id)
if ret == -1:
stat["errors"] += 1
elif ret == -2:
stat["skipped"] += 1
else:
stat["new"] += 1
if progress and i % 20 == 0 and i > 0:
logger.info(f" [{subdir}] {i} 文件已处理...")
logger.info(f" [{subdir}] 共 {stat['total']} 文件, "
f"新增 {stat['new']}, 跳过 {stat['skipped']}, 错误 {stat['errors']}")
return stat
def scan_all(self, production_id: int = 0, progress: bool = True) -> dict:
"""
扫描所有已知素材目录。
返回:
{"total_new": int, "dirs": {subdir: stat_dict}}
"""
results: dict = {"total_new": 0, "dirs": {}}
for subdir in WATCHED_SUBDIRS:
stat = self.scan_dir(subdir, production_id, progress)
results["dirs"][subdir] = stat
results["total_new"] += stat["new"]
logger.info(f"全量扫描完成, 新增 {results['total_new']} 个素材")
return results
# ── 统计查询 ──────────────────────────────────────────
def summarize(self) -> dict:
"""汇总当前素材库统计信息"""
conn = sqlite3.connect(str(self.db_path))
try:
total = conn.execute("SELECT COUNT(*) FROM assets").fetchone()[0]
by_type = conn.execute(
"SELECT asset_type, COUNT(*) as cnt FROM assets GROUP BY asset_type"
).fetchall()
by_source = conn.execute(
"SELECT source, COUNT(*) as cnt FROM assets GROUP BY source"
).fetchall()
total_size = conn.execute(
"SELECT COALESCE(SUM(file_size), 0) FROM assets"
).fetchone()[0]
orphan = conn.execute(
"SELECT COUNT(*) FROM assets WHERE production_id IS NULL"
).fetchone()[0]
return {
"total_assets": total,
"by_type": dict(by_type),
"by_source": dict(by_source),
"total_size_bytes": total_size,
"total_size_mb": round(total_size / (1024 * 1024), 1),
"orphan_count": orphan,
"watched_dirs": list(WATCHED_SUBDIRS.keys()),
}
finally:
conn.close()
def print_summary(self):
"""打印人类可读的统计"""
s = self.summarize()
print(f"\n{'='*50}")
print(f" 素材库统计")
print(f"{'='*50}")
print(f" 素材总数: {s['total_assets']}")
print(f" 总大小: {s['total_size_mb']} MB")
print(f" 无关联素材: {s['orphan_count']}")
print(f"\n ─ 按类型 ─")
for t, c in sorted(s["by_type"].items(), key=lambda x: -x[1]):
print(f" {t:12s} {c}")
print(f"\n ─ 按来源 ─")
for src, c in sorted(s["by_source"].items(), key=lambda x: -x[1]):
print(f" {src:16s} {c}")
print(f"\n ─ 监控目录 ─")
for d in s["watched_dirs"]:
print(f" {d}")
print(f"{'='*50}\n")
# ═══════════════════════════════════════════════════════════
# 辅助函数
# ═══════════════════════════════════════════════════════════
def _parse_fps(r_frame_rate: str) -> float:
"""解析 ffprobe 的 r_frame_rate (如 '30000/1001' → 29.97)"""
try:
if "/" in r_frame_rate:
num, den = r_frame_rate.split("/")
return round(float(num) / float(den), 2)
return float(r_frame_rate)
except (ValueError, ZeroDivisionError):
return 0.0
def _human_size(bytes_: int) -> str:
if bytes_ < 1024:
return f"{bytes_} B"
elif bytes_ < 1024**2:
return f"{bytes_/1024:.1f} KB"
elif bytes_ < 1024**3:
return f"{bytes_/(1024**2):.1f} MB"
return f"{bytes_/(1024**3):.2f} GB"
@@ -0,0 +1,317 @@
"""
asset_manager/tags — 标签系统 + 素材搜索
职责:
1. 自动标签: 根据文件属性和路径生成标签 (复用 index.py 的 _infer_tags)
2. 标签管理: 查看所有标签、标签频次、批量标记
3. 搜素引擎: 按名称/路径/标签/类型/来源 多维度过滤
4. 搜索结果格式化输出 (表格/JSON)
用法:
from asset_manager.tags import AssetSearch
srch = AssetSearch()
# 搜索
results = srch.search("人物", tags=["video"])
results = srch.search_by_tag("pexels", limit=20)
# 标签管理
srch.tag_stats() # 所有标签频次
srch.tag_asset(42, ["人物", "户外"]) # 给资产打标签
"""
import json
import os
import sqlite3
from datetime import datetime
from pathlib import Path
from typing import Optional, Union
from lib.logger import get_logger
logger = get_logger("asset_tags")
# ── 全局路径 ─────────────────────────────────────────────
_LOCAL_ROOT = Path(os.environ.get(
"AVE_LOCAL_DIR",
str(Path.home() / "workbuddy-agent-os/agent-local"),
))
_DB_PATH = _LOCAL_ROOT / "tools" / "ave" / "data" / "ave.db"
# ═══════════════════════════════════════════════════════════
# 搜索类
# ═══════════════════════════════════════════════════════════
class AssetSearch:
"""素材搜索引擎 (基于 SQLite LIKE + 标签过滤)"""
def __init__(self, db_path: Union[str, Path] = _DB_PATH):
self.db_path = Path(db_path)
def _connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(str(self.db_path))
conn.row_factory = sqlite3.Row
return conn
# ── 搜索 ─────────────────────────────────────────────
def search(
self,
keyword: str = "",
*,
asset_type: Optional[str] = None,
source: Optional[str] = None,
tags: Optional[list[str]] = None,
min_size: int = 0,
max_size: int = 0,
min_duration: float = 0.0,
max_duration: float = 0.0,
production_id: Optional[int] = None,
limit: int = 50,
offset: int = 0,
order_by: str = "id DESC",
) -> list[dict]:
"""
多维度素材搜索。
参数:
keyword: 关键字 (模糊匹配 name/file_path)
asset_type: 文件类型 (video/audio/image/other)
source: 来源 (materials/kling/wan2_2/bgm/...)
tags: 标签列表 (AND 逻辑, 需同时命中)
min_size: 最小文件大小 (bytes)
max_size: 最大文件大小 (0 = 不限制)
min_duration: 最小时长 (秒)
max_duration: 最大时长 (0 = 不限制)
production_id:关联 production ID
limit/offset: 分页
order_by: 排序 (SQL 片段, 如 "file_size DESC")
返回:
[{
"id", "asset_type", "name", "file_path", "file_size",
"hash", "source", "meta", "tags", "created_at",
"production_id"
}, ...]
"""
conn = self._connect()
try:
where = []
params: list = []
# 关键字
if keyword:
like = f"%{keyword}%"
where.append("(a.name LIKE ? OR a.file_path LIKE ?)")
params.extend([like, like])
# 类型
if asset_type:
where.append("a.asset_type = ?")
params.append(asset_type)
# 来源
if source:
where.append("a.source = ?")
params.append(source)
# 标签 (AND: 需要所有指定标签)
if tags:
tag_conditions = []
for tag in tags:
tag_conditions.append(
"EXISTS (SELECT 1 FROM asset_tags WHERE asset_id = a.id AND tag = ?)"
)
params.append(tag.lower())
where.append("(" + " AND ".join(tag_conditions) + ")")
# 文件大小
if min_size > 0:
where.append("a.file_size >= ?")
params.append(min_size)
if max_size > 0:
where.append("a.file_size <= ?")
params.append(max_size)
# 时长 (meta_json 中的 duration_sec)
if min_duration > 0:
where.append(
"json_extract(a.meta_json, '$.duration_sec') >= ?"
)
params.append(min_duration)
if max_duration > 0:
where.append(
"json_extract(a.meta_json, '$.duration_sec') <= ?"
)
params.append(max_duration)
# production
if production_id is not None:
where.append("a.production_id = ?")
params.append(production_id)
# 排除内部记录
where.append("a.asset_type != '__meta__'")
where_clause = " AND ".join(where) if where else "1=1"
# 安全校验 order_by (防止 SQL 注入)
allowed_orders = {
"id", "id DESC", "id ASC",
"file_size", "file_size DESC", "file_size ASC",
"created_at", "created_at DESC", "created_at ASC",
"name", "name DESC", "name ASC",
}
safe_order = order_by if order_by in allowed_orders else "id DESC"
rows = conn.execute(
f"""SELECT a.*, GROUP_CONCAT(at.tag, ',') as tag_list
FROM assets a
LEFT JOIN asset_tags at ON at.asset_id = a.id
WHERE {where_clause}
GROUP BY a.id
ORDER BY {safe_order}
LIMIT ? OFFSET ?""",
params + [limit, offset],
).fetchall()
results = []
for r in rows:
d = dict(r)
# 解析 meta_json
try:
d["meta"] = json.loads(d.pop("meta_json", "{}") or "{}")
except json.JSONDecodeError:
d["meta"] = {}
# 标签列表
d["tags"] = d.pop("tag_list", "").split(",") if d.get("tag_list") else []
d["file_size_human"] = _human_size(d.get("file_size", 0))
results.append(d)
return results
except Exception as e:
logger.error(f"search 失败: {e}")
return []
finally:
conn.close()
# ── 标签搜索 ─────────────────────────────────────────
def search_by_tag(self, tag: str, limit: int = 50) -> list[dict]:
"""按单个标签搜索"""
return self.search(tags=[tag], limit=limit)
def search_by_type(self, asset_type: str, limit: int = 50) -> list[dict]:
"""按类型搜索"""
return self.search(asset_type=asset_type, limit=limit)
# ── 标签管理 ─────────────────────────────────────────
def tag_stats(self) -> list[dict]:
"""返回所有标签及出现频次, 按频次降序"""
conn = self._connect()
try:
rows = conn.execute(
"""SELECT tag, COUNT(*) as count
FROM asset_tags
GROUP BY tag
ORDER BY count DESC"""
).fetchall()
return [dict(r) for r in rows]
finally:
conn.close()
def tag_asset(self, asset_id: int, tags: list[str]) -> bool:
"""给指定资产打标签"""
conn = self._connect()
try:
for tag in tags:
tag_clean = tag.strip().lower()
if tag_clean:
conn.execute(
"INSERT OR IGNORE INTO asset_tags (asset_id, tag) VALUES (?, ?)",
(asset_id, tag_clean),
)
conn.commit()
logger.info(f"资产 {asset_id} 标记: {tags}")
return True
except Exception as e:
logger.error(f"标记失败 asset {asset_id}: {e}")
return False
finally:
conn.close()
def untag_asset(self, asset_id: int, tags: list[str]) -> bool:
"""移除标签"""
conn = self._connect()
try:
for tag in tags:
conn.execute(
"DELETE FROM asset_tags WHERE asset_id = ? AND tag = ?",
(asset_id, tag.strip().lower()),
)
conn.commit()
return True
except Exception as e:
logger.error(f"移除标签失败: {e}")
return False
finally:
conn.close()
def print_results(self, results: list[dict], show_meta: bool = False):
"""打印搜索结果表格"""
if not results:
print(" 未找到匹配的素材")
return
print(f"\n {'ID':>4s} {'类型':8s} {'来源':12s} {'大小':10s} {'名称'}")
print(f" {'-'*80}")
for r in results:
print(f" {r['id']:>4d} {r['asset_type']:8s} {r['source'] or '?':12s} "
f"{r.get('file_size_human', ''):>10s} {r['name'] or '?'}")
if show_meta and r.get("meta"):
meta = r["meta"]
parts = []
if meta.get("duration_sec"):
parts.append(f"dur={meta['duration_sec']}s")
if meta.get("width"):
parts.append(f"{meta['width']}x{meta['height']}")
if meta.get("fps"):
parts.append(f"{meta['fps']}fps")
if parts:
print(f" {'':>4s} {'':8s} {'':12s} {'':10s} [{', '.join(parts)}]")
if r.get("tags"):
print(f" {'':>4s} {'':8s} {'':12s} {'':10s} #{', #'.join(r['tags'][:5])}")
print(f" {'-'*80}")
print(f" 共 {len(results)} 条结果\n")
def print_tag_cloud(self):
"""打印标签云"""
stats = self.tag_stats()
if not stats:
print(" 暂无标签")
return
max_count = max(s["count"] for s in stats) if stats else 1
print(f"\n {'标签':24s} {'频次':>6s} 分布")
print(f" {'-'*60}")
for s in stats:
bar_len = int(s["count"] / max_count * 20)
bar = "█" * bar_len + "░" * (20 - bar_len)
print(f" {s['tag']:24s} {s['count']:>6d} {bar}")
print(f" 共 {len(stats)} 个标签\n")
# ═══════════════════════════════════════════════════════════
# 辅助函数
# ═══════════════════════════════════════════════════════════
def _human_size(bytes_: int) -> str:
if bytes_ < 1024:
return f"{bytes_} B"
elif bytes_ < 1024**2:
return f"{bytes_/1024:.1f} KB"
elif bytes_ < 1024**3:
return f"{bytes_/(1024**2):.1f} MB"
return f"{bytes_/(1024**3):.2f} GB"
@@ -0,0 +1,421 @@
"""
assets_manager — 视频工厂资源库(数据层)
==========================================
按《DCS 说明说明书 v2.0》第五节「资产库规范」实现。
目录结构(默认 `agent-local/tools/ave/assets/`):
characters/{char_id}/
meta.json 元数据(名字/类型/用途/标签/一致性种子…)
ref_front.png 正面参考
ref_side.png 侧面
ref_back.png 背面
ref_full.png 全身
expressions/ 表情图(happy/sad/angry…)
wardrobe/{w_id}.png 服装变体
voice_sample.mp3 音色样本
locations/{loc_id}/ AI 生成场景
real_locations/{loc_id}/ 真实场地
products/{prod_id}/ 产品(含 must_keep)
props/{prop_id}/ 道具
voices/{voice_id}/ 音色
bgm/ sfx/ vfx_presets/
设计要点:
· 每个资产 = 一个目录(图/音/元数据分离,便于直接喂给 API)
· meta.json 是唯一真相源(列表/筛选都读它)
· 支持旧数据迁移(character_registry/registry.yaml → characters/)
"""
from __future__ import annotations
import json
import logging
import os
import re
import shutil
import time
from pathlib import Path
from typing import Any, Optional
log = logging.getLogger("assets_manager")
# 资产类型 → 目录名
ASSET_DIRS = {
"characters": "角色",
"locations": "场景",
"real_locations": "真实场地",
"products": "产品",
"props": "道具",
"wardrobe": "服装",
"voices": "音色",
"bgm": "BGM",
"sfx": "音效",
"ambience": "环境音",
"vfx_presets": "特效预设",
"materials": "素材",
}
# 角色:类型 / 用途枚举
CHAR_TYPES = {"real": "真人", "anime": "动漫", "3d": "3D"}
CHAR_ROLES = {"lead": "主角", "support": "配角", "extra": "群演"}
def assets_root() -> Path:
"""资源库根目录(可用 AVE_ASSETS_DIR 覆盖)"""
p = os.environ.get("AVE_ASSETS_DIR")
if p:
return Path(p)
base = Path(os.environ.get("AGENT_LOCAL")
or (Path.home() / "workbuddy-agent-os" / "agent-local"))
return base / "tools" / "ave" / "assets"
# 🔒 锁定后受保护的字段(关键一致性字段:改动会破坏角色一致性 / 版本可追溯)
LOCK_PROTECTED_FIELDS = (
"reference_images", "generation_history", "active_version",
"consistency_seed", "voice", "locked",
)
def _slug(name: str) -> str:
"""中文/特殊字符 → 安全 ID(保留字母数字与中文,其余转下划线)"""
s = re.sub(r"[^\w\u4e00-\u9fff]+", "_", (name or "").strip())
return s.strip("_") or f"item_{int(time.time())}"
class AssetStore:
"""通用资产仓库(一个子目录 = 一个资产)"""
def __init__(self, kind: str, root: Optional[Path] = None):
if kind not in ASSET_DIRS:
raise ValueError(f"未知资产类型: {kind}(可选: {list(ASSET_DIRS)})")
self.kind = kind
self.dir = (root or assets_root()) / kind
self.dir.mkdir(parents=True, exist_ok=True)
# ── 读 ─────────────────────────────────────────────
def _meta_path(self, aid: str) -> Path:
return self.dir / aid / "meta.json"
def kind_dir(self, aid: str) -> Path:
"""某资产的目录(`assets/<kind>/<aid>/`)
供外部写入文件用:参考图(`ref_front.png`)/ 音色(`voice_sample.mp3`)等。
"""
d = self.dir / aid
d.mkdir(parents=True, exist_ok=True)
return d
def get(self, aid: str) -> Optional[dict]:
p = self._meta_path(aid)
if not p.exists():
return None
try:
d = json.loads(p.read_text(encoding="utf-8"))
d.setdefault("id", aid)
d["files"] = self._list_files(aid)
return d
except Exception as e:
log.warning("读取 %s 失败: %s", p, e)
return None
def _list_files(self, aid: str) -> dict:
"""列出资产目录下的文件(供前端展示)"""
d = self.dir / aid
out: dict[str, list[str]] = {}
if not d.exists():
return out
for f in sorted(d.rglob("*")):
if f.is_file() and f.name != "meta.json":
rel = str(f.relative_to(d))
out.setdefault(f.parent.name if f.parent != d else "root", []).append(rel)
return out
def list(self, q: str = "", filters: Optional[dict] = None,
sort: str = "updated_desc", limit: int = 500) -> list[dict]:
"""列表(带搜索/筛选/排序)
q: 关键词(匹配 name / tags / description)
filters: 字段等值筛选(如 {"type": "real", "role": "lead"})
sort: updated_desc | updated_asc | name | usage
"""
filters = {k: v for k, v in (filters or {}).items() if v not in (None, "", [])}
out: list[dict] = []
if not self.dir.exists():
return out
for sub in sorted(self.dir.iterdir()):
if not sub.is_dir():
continue
m = self.get(sub.name)
if not m:
continue
if filters and any(str(m.get(k, "")) != str(v) for k, v in filters.items()):
continue
if q:
hay = " ".join([
str(m.get("name", "")), str(m.get("description", "")),
" ".join(m.get("tags") or []), str(m.get("project", "")),
]).lower()
if q.lower() not in hay:
continue
out.append(m)
if sort == "name":
out.sort(key=lambda x: str(x.get("name", "")))
elif sort == "usage":
out.sort(key=lambda x: -int(x.get("usage_count") or 0))
elif sort == "updated_asc":
out.sort(key=lambda x: str(x.get("updated_at", "")))
else: # updated_desc(默认)
out.sort(key=lambda x: str(x.get("updated_at", "")), reverse=True)
return out[:limit]
# ── 写 ─────────────────────────────────────────────
def create(self, data: dict, aid: Optional[str] = None) -> dict:
# 优先用显式 id(调用方可指定稳定标识),否则由 name 生成
# ⚠️ 原实现写成 name 优先 → 传入的 id 被忽略(曾导致上传时按 id 找不到资产)
aid = aid or _slug(data.get("id") or data.get("name") or "")
d = self.dir / aid
d.mkdir(parents=True, exist_ok=True)
now = time.strftime("%Y-%m-%d %H:%M:%S")
meta = {**data}
meta["id"] = aid
meta.setdefault("created_at", now)
meta["updated_at"] = now
meta.setdefault("usage_count", 0)
(d / "meta.json").write_text(
json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
log.info(" ➕ 新建 %s: %s", ASSET_DIRS[self.kind], aid)
return self.get(aid) or meta
def update(self, aid: str, patch: dict) -> Optional[dict]:
cur = self.get(aid)
if not cur:
return None
# 🔒 锁定保护:已锁定的资产不允许修改「关键字段」(保证角色一致性 / 版本可追溯)
if cur.get("locked"):
blocked = [k for k in patch if k in LOCK_PROTECTED_FIELDS]
if blocked:
raise PermissionError(
f"资产已锁定({cur.get('locked_at') or ''}),不能修改:{'、'.join(blocked)}。"
"如需修改请先解锁。")
cur.pop("files", None)
cur.update({k: v for k, v in patch.items() if k not in ("id", "created_at")})
cur["updated_at"] = time.strftime("%Y-%m-%d %H:%M:%S")
(self.dir / aid / "meta.json").write_text(
json.dumps(cur, ensure_ascii=False, indent=2), encoding="utf-8")
log.info(" ✏️ 更新 %s: %s", ASSET_DIRS[self.kind], aid)
return self.get(aid)
# ── 🔒 锁定 / 版本记录(角色一致性保障)────────────────
def lock(self, aid: str, reason: str = "", by: str = "user") -> Optional[dict]:
"""锁定资产 → 冻结关键字段(reference_images / 生成历史 / seed / 音色)
何时锁定:
· 用户手动点「🔒 锁定」
· 首次被流程引用(`bump_usage` 自动锁定)
锁定后如需修改,必须先 `unlock()`(会记入 `unlock_history`,便于追溯)。
"""
cur = self.get(aid)
if not cur:
return None
cur.pop("files", None)
if cur.get("locked"):
return self.get(aid)
cur["locked"] = True
cur["locked_at"] = time.strftime("%Y-%m-%d %H:%M:%S")
cur["locked_reason"] = reason or "手动锁定"
cur["locked_by"] = by
(self.dir / aid / "meta.json").write_text(
json.dumps(cur, ensure_ascii=False, indent=2), encoding="utf-8")
log.info(" 🔒 锁定 %s: %s(%s)", ASSET_DIRS[self.kind], aid, cur["locked_reason"])
return self.get(aid)
def unlock(self, aid: str, reason: str = "") -> Optional[dict]:
"""解锁(改动会记入解锁历史,供版本追溯)"""
cur = self.get(aid)
if not cur or not cur.get("locked"):
return self.get(aid) if cur else None
cur.pop("files", None)
hist = list(cur.get("unlock_history") or [])
hist.append({
"at": time.strftime("%Y-%m-%d %H:%M:%S"),
"was_locked_at": cur.get("locked_at"),
"reason": reason or "手动解锁",
})
cur["unlock_history"] = hist
cur["locked"] = False
cur.pop("locked_at", None)
cur.pop("locked_reason", None)
(self.dir / aid / "meta.json").write_text(
json.dumps(cur, ensure_ascii=False, indent=2), encoding="utf-8")
log.info(" 🔓 解锁 %s: %s", ASSET_DIRS[self.kind], aid)
return self.get(aid)
def add_generation(self, aid: str, record: dict) -> Optional[dict]:
"""追加一次**生成记录**(版本历史)—— 记录 prompt / seed / 模型 / 引擎
record 建议字段:
prompt, negative_prompt, seed, engine, model, slot, file, params, at
→ 自动编号 version,并更新 active_version + last_generation
"""
cur = self.get(aid)
if not cur:
return None
cur.pop("files", None)
hist = list(cur.get("generation_history") or [])
rec = {**record}
rec.setdefault("at", time.strftime("%Y-%m-%d %H:%M:%S"))
rec["version"] = len(hist) + 1
hist.append(rec)
cur["generation_history"] = hist
cur["active_version"] = rec["version"]
cur["last_generation"] = rec
# 若 record 带 slot + file → 同步写入参考图(**锁定状态下也允许新增/更新该槽位**,
# 因为这是"追加新版本产物",不是篡改已有设定)
if rec.get("slot") and rec.get("file"):
refs = dict(cur.get("reference_images") or {})
refs[rec["slot"]] = rec["file"]
cur["reference_images"] = refs
# 记录 seed(供后续生成保持一致)
if rec.get("seed") is not None and not cur.get("consistency_seed"):
cur["consistency_seed"] = rec["seed"]
cur["updated_at"] = time.strftime("%Y-%m-%d %H:%M:%S")
(self.dir / aid / "meta.json").write_text(
json.dumps(cur, ensure_ascii=False, indent=2), encoding="utf-8")
log.info(" 📌 %s 生成记录 v%d(%s / %s / seed=%s)",
aid, rec["version"], rec.get("engine"), rec.get("model"), rec.get("seed"))
return self.get(aid)
def delete(self, aid: str, hard: bool = False) -> bool:
d = self.dir / aid
if not d.exists():
return False
if hard:
shutil.rmtree(d)
else: # 软删:移到 _trash/
t = self.dir / "_trash"
t.mkdir(exist_ok=True)
shutil.move(str(d), str(t / f"{aid}_{int(time.time())}"))
log.info(" 🗑 删除 %s: %s", ASSET_DIRS[self.kind], aid)
return True
def bump_usage(self, aid: str) -> None:
"""使用次数 +1(流程引用资产时调用)"""
m = self.get(aid)
if not m:
return
m.pop("files", None)
m["usage_count"] = int(m.get("usage_count") or 0) + 1
# 🆕 首次被使用 → 自动锁定(用户要求:"角色开始使用就要锁定",冻结关键字段保证一致性)
if not m.get("locked"):
m["locked"] = True
m["locked_at"] = time.strftime("%Y-%m-%d %H:%M:%S")
m["locked_reason"] = f"已被流程引用(第 {m['usage_count']} 次,自动锁定以保证角色一致性)"
m["locked_by"] = "auto"
(self.dir / aid / "meta.json").write_text(
json.dumps(m, ensure_ascii=False, indent=2), encoding="utf-8")
def save_file(self, aid: str, filename: str, content: bytes) -> Path:
"""保存资产文件(参考图/音色等)"""
d = self.dir / aid
d.mkdir(parents=True, exist_ok=True)
sub = d / filename
sub.parent.mkdir(parents=True, exist_ok=True)
sub.write_bytes(content)
return sub
class CharacterStore(AssetStore):
"""角色库(AssetStore 的特化:带类型/用途枚举、使用统计)"""
def __init__(self, root: Optional[Path] = None):
super().__init__("characters", root)
def stats(self) -> dict:
"""统计(给 UI 显示)"""
items = self.list(limit=10000)
by_type: dict[str, int] = {}
by_role: dict[str, int] = {}
with_voice = 0
for m in items:
by_type[str(m.get("type") or "real")] = by_type.get(str(m.get("type") or "real"), 0) + 1
by_role[str(m.get("role") or "lead")] = by_role.get(str(m.get("role") or "lead"), 0) + 1
if (m.get("voice") or {}).get("sample"):
with_voice += 1
return {"total": len(items), "by_type": by_type, "by_role": by_role,
"with_voice": with_voice}
def migrate_from_registry(registry_path: Optional[str] = None,
root: Optional[Path] = None) -> dict:
"""旧角色库(character_registry/registry.yaml)→ 新目录结构
保留原字段,映射到新 schema;已存在的角色不覆盖(除非 force)。
"""
import yaml
scripts = Path(__file__).resolve().parent.parent # 09_ave/scripts/
p = Path(registry_path) if registry_path else (scripts / "character_registry" / "registry.yaml")
if not p.exists():
return {"ok": False, "reason": f"旧角色库不存在: {p}"}
raw = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
chars = raw.get("characters") or {}
store = CharacterStore(root)
created, skipped = 0, 0
reg_dir = p.parent
for name, c in chars.items():
aid = _slug(name)
if (store.dir / aid / "meta.json").exists():
skipped += 1
continue
# 旧 reference_images → 新 reference_images
ri = c.get("reference_images") or {}
refs: dict[str, str] = {}
for k in ("front", "side", "back", "full", "portrait", "full_body"):
if ri.get(k):
refs[k] = str(ri[k])
body = ri.get("body") or {}
for k, v in (body.items() if isinstance(body, dict) else []):
if v:
refs[f"body_{k}"] = str(v)
store.create({
"name": name,
"type": "anime", # 旧库多为漫剧风,默认 anime(可改)
"role": "lead",
"project": "",
"tags": list(c.get("preferred_bgm_tags") or []),
"description": c.get("description") or "",
"appearance": list(c.get("appearance") or []),
"personality": c.get("personality") or "",
"art_style": c.get("art_style") or "",
"voice_style": c.get("voice_style") or "",
"bgm_style": c.get("default_bgm_style") or "",
"prompt_template": c.get("prompt_template") or {},
"reference_images": refs,
"consistency_seed": 42,
"voice": {},
"wardrobe": [],
"lora": None,
"_migrated_from": str(p),
}, aid=aid)
created += 1
# 同时把旧图拷进新目录(如果存在)
try:
for k, rel in refs.items():
src = (reg_dir / rel) if not Path(rel).is_absolute() else Path(rel)
if src.exists() and src.is_file():
store.save_file(aid, f"ref_{k}{src.suffix}", src.read_bytes())
except Exception as e:
log.debug("拷贝参考图失败 %s: %s", name, e)
log.info(" 迁移完成: 新建 %d / 跳过 %d(共 %d)", created, skipped, len(chars))
return {"ok": True, "created": created, "skipped": skipped, "total": len(chars)}
# 便捷入口
def get_store(kind: str = "characters") -> AssetStore:
return CharacterStore() if kind == "characters" else AssetStore(kind)
@@ -0,0 +1,198 @@
"""
AudioLine — 音频创作管线
独立于 AVE 视频工厂的音频处理线。
支持卡点改编、换词创作、风格迁移、Remix 等。
快速开始:
from audio_line import AudioLine
# 1. 初始化管线
al = AudioLine(config_path="config.yaml")
# 2. 从文件创建任务
task = al.from_file("input.mp3", operation="cardio", target_bpm=128)
# 3. 执行全管线
result = al.run(task)
# 4. 查看结果
print(result.summary)
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from .orchestrator import AudioLineOrchestrator, AudioLineResult
from .layer1_input.collector import AudioTask, AudioSource, AudioCollector
from .layer2_analysis.beat_detector import BeatResult
from .layer2_analysis.structure_parser import StructureResult
from .layer2_analysis.emotion_analyzer import EmotionResult
from .layer3_creation.module_a_rhythm import RhythmResult
from .layer3_creation.module_b_lyrics import LyricsResult
from .layer3_creation.module_c_melody import MelodyResult
from .layer3_creation.module_d_mix import MixResult
from .layer4_alignment.lyric_aligner import LyricTimeline
from .layer5_output.exporter import ExportResult
class AudioLine:
"""
AudioLine — 音频创作管线外部 API
封装 AudioLineOrchestrator,提供简洁的接口。
支持从文件/URL/指令创建任务并执行全管线。
"""
def __init__(self, config_path: str | Path | None = None):
"""
初始化管线
参数:
config_path: 配置文件路径 (YAML)
"""
self._orchestrator = AudioLineOrchestrator(config_path)
self._collector = self._orchestrator.collector
# ---- 任务创建 ----
def from_file(
self,
path: str | Path,
operation: str = "auto",
target_bpm: int = 0,
target_key: str = "",
style: str = "",
topic: str = "",
duration: float = 0.0,
dry_run: bool = False,
modules: set[str] | None = None,
**kwargs,
) -> AudioTask:
"""
从本地文件创建音频处理任务
参数:
path: 音频文件路径
operation: 操作类型 (auto/remix/rewrite/speed_up/cardio)
target_bpm: 目标 BPM (0=自动)
target_key: 目标调性
style: 目标风格
topic: 歌词主题 (换词时使用)
duration: 目标时长 (秒)
dry_run: 仅分析不创作
modules: 模块子集
返回:
AudioTask
"""
return self._collector.from_file(path, {
"operation": operation,
"target_bpm": target_bpm,
"target_key": target_key,
"style": style,
"topic": topic,
"duration": duration,
"dry_run": dry_run,
"modules": modules or {"separation", "analysis", "rhythm", "lyrics", "mix", "alignment", "export"},
**kwargs,
})
def from_url(
self,
url: str,
**kwargs,
) -> AudioTask:
"""
从 URL 下载并创建任务
参数同 from_file()
"""
return self._collector.from_url(url, kwargs)
def from_command(self, text: str) -> AudioTask:
"""
从自然语言指令创建任务
参数:
text: 如 "把这首歌改成128BPM的卡点版"
"""
return self._collector.from_text_command(text)
# ---- 管线执行 ----
def run(self, task: AudioTask) -> AudioLineResult:
"""
执行音频处理管线
参数:
task: AudioTask 任务对象
返回:
AudioLineResult
"""
return self._orchestrator.run(task)
def run_file(
self,
path: str | Path,
**kwargs,
) -> AudioLineResult:
"""
快速执行: 从文件到结果一步完成
参数:
path: 音频文件路径
**kwargs: 传给 from_file() 的额外参数
返回:
AudioLineResult
"""
task = self.from_file(path, **kwargs)
return self._orchestrator.run(task)
# ---- 单模块执行 ----
@property
def config(self) -> dict:
"""获取当前配置"""
return self._orchestrator.config
@property
def stem_separator(self):
"""直接访问音轨分离器"""
return self._orchestrator.stem_sep
@property
def beat_detector(self):
"""直接访问节拍检测器"""
return self._orchestrator.beat_det
@property
def structure_parser(self):
"""直接访问结构解析器"""
return self._orchestrator.structure_parser
@property
def emotion_analyzer(self):
"""直接访问情绪分析器"""
return self._orchestrator.emotion_analyzer
__all__ = [
"AudioLine",
"AudioLineOrchestrator",
"AudioLineResult",
"AudioTask",
"AudioSource",
"BeatResult",
"StructureResult",
"EmotionResult",
"RhythmResult",
"LyricsResult",
"MelodyResult",
"MixResult",
"LyricTimeline",
"ExportResult",
]
@@ -0,0 +1,85 @@
# audio_line —— 音频创作管线配置
# 复制到 agent-local/tools/ave/config/local.yaml 并填入真实 API 密钥
# 优先级: local.yaml > config.yaml > 默认值
# === 输入/输出路径 ===
paths:
work_dir: "./work/audio_line" # 临时工作目录
output_dir: "./output/audio_line" # 成品输出目录
model_cache: "./cache/models" # 模型缓存
# === 音轨分离 ===
separation:
model: "htdemucs_ft" # Demucs 模型: htdemucs | htdemucs_ft | htdemucs_6s
device: "cpu" # cpu | mps (M1/M2/M3 GPU)
shifts: 2 # 推理增强次数 (≥1), >1 提高质量但慢
overlap: 0.25 # 分帧重叠率
segments: "default" # 分帧长度 "default" | int
# === BPM/节拍检测 ===
beat:
min_bpm: 60 # 最小 BPM
max_bpm: 200 # 最大 BPM
onset_backend: "librosa" # 节拍 onset 后端
downbeat_model: "madmom" # 重拍检测模型 (madmom | librosa)
# === 节奏重塑 (Mubert API) ===
mubert:
api_key: "" # Mubert API Key
base_url: "https://api.mubert.com/v2"
default_duration: 30 # 默认生成时长(秒)
default_genre: "electronic" # 默认风格
default_bpm: 128 # 默认 BPM
max_retries: 3
timeout: 60
# === 歌词创作 (oMLX 本地 LLM) ===
lyrics:
llm_url: "http://localhost:8000/v1" # oMLX 地址
llm_model: "Qwen3-8B" # 可用模型
max_tokens: 1024
temperature: 0.8
# 提示词模板
rewrite_prompt: >
你是一位专业歌词改编者。请根据以下要求对原曲歌词进行二次创作:
主题: {topic}
风格: {style}
保留原曲的段落结构(Intro/Verse/Chorus/Bridge/Outro)和押韵模式。
输出格式: 每段以[Intro][Verse][Chorus]标记开头。
lyric_analysis_prompt: >
分析以下歌词的段落结构、押韵模式和情感走向。
输出 JSON 格式。
# === 旋律编曲 (Suno API) ===
suno:
api_key: "" # Suno API Key
base_url: "https://api.suno.ai/v1"
default_duration: 30 # 默认生成时长
model: "suno-v4" # Suno 模型版本
max_retries: 2
timeout: 120
# === 混音母带 ===
mixing:
target_loudness: -14.0 # LUFS 目标响度 (Spotify: -14, YouTube: -14, Apple: -16)
headroom: 1.0 # 峰值头上空间 (dB)
sample_rate: 44100 # 输出采样率
bit_depth: 16 # 输出位深
eq_preset: "default" # EQ 预设
compressor_threshold: -20 # 压缩器阈值 (dB)
compressor_ratio: 3.0 # 压缩比
compressor_attack: 5 # 启动时间 (ms)
compressor_release: 100 # 释放时间 (ms)
# === 对齐 ===
alignment:
time_tolerance: 0.1 # 时间对准容差 (秒)
crossfade: 0.05 # 交叉淡入淡出时长 (秒)
stretch_method: "phase_vocoder" # 时间伸缩方法: phase_vocoder | wsola | rubberband
# === 输出 ===
export:
formats: ["wav", "mp3"] # 默认导出格式
mp3_bitrate: "320k" # MP3 比特率
include_stems: true # 是否同时导出分轨
stem_labels: ["vocals", "drums", "bass", "other", "full_mix"]
@@ -0,0 +1,52 @@
"""
layer1_input/_parser.py — 自然语言指令解析
将用户的口语化指令(如"把这首歌改快一点、加卡点")解析为 AudioTask。
当前用规则匹配,后续可接入 LLM 增强。
"""
from .collector import AudioTask, AudioSource
def parse_command_text(text: str) -> AudioTask:
"""解析自然语言指令 → AudioTask (不含音频源)"""
text = text.strip().lower()
modules = {"separation", "analysis", "rhythm", "lyrics", "mix"}
operation = "auto"
target_bpm = 0
# — 检测操作类型 —
if any(kw in text for kw in ["卡点", "变速", "改快", "改慢"]):
operation = "cardio"
elif any(kw in text for kw in ["换词", "改词", "填词", "翻唱"]):
operation = "rewrite"
elif any(kw in text for kw in ["remix", "改编", "混音"]):
operation = "remix"
elif any(kw in text for kw in ["加速", "慢速", "变速度"]):
operation = "speed_up"
# — 检测 BPM —
import re
bpm_match = re.search(r"(\d+)\s*bpm", text)
if bpm_match:
target_bpm = int(bpm_match.group(1))
# — 模块选择 —
if "只分析" in text or "仅分析" in text:
modules = {"separation", "analysis"}
# — 主题/风格提取 (简单规则) —
style = ""
for kw, st in [("可爱", "可爱"), ("伤感", "伤感"), ("燃", "燃系"), ("DJ", "电音"),
("古风", "古风"), ("摇滚", "摇滚"), ("爵士", "爵士")]:
if kw in text:
style = st
break
return AudioTask(
source=AudioSource(path="", source_type="command"),
operation=operation,
target_bpm=target_bpm,
style=style,
modules=modules,
)
@@ -0,0 +1,133 @@
"""
layer1_input/collector.py — 输入收集模块
职责:接收各种来源的输入,标准化为统一的 AudioTask 数据类。
支持:本地文件 / URL下载 / 文本指令(如"把这首歌改成卡点版")
"""
from __future__ import annotations
import os
import tempfile
import urllib.request
from dataclasses import dataclass, field
from pathlib import Path
from typing import Literal
# ---------------------------------------------------------------------------
# 数据类
# ---------------------------------------------------------------------------
@dataclass
class AudioSource:
"""标准化音频源描述"""
path: str # 本地路径
original_filename: str = "" # 原始文件名
source_type: Literal["file", "url", "command"] = "file"
duration: float = 0.0 # 时长 (秒),预读取后填充
sample_rate: int = 0 # 采样率,预读取后填充
@dataclass
class AudioTask:
"""完整的音频处理任务"""
# ——— 源信息 ———
source: AudioSource
# ——— 创作参数 ———
operation: Literal["remix", "rewrite", "speed_up", "cardio", "auto"] = "auto"
target_bpm: int = 0 # 0 = 自动
target_key: str = "" # 空 = 保持原调
style: str = "" # 目标风格描述
topic: str = "" # 歌词主题 (换词时使用)
duration: float = 0.0 # 目标时长 (0 = 与原曲相同)
# ——— 控制标记 ———
modules: set[str] = field(default_factory=lambda: {
"separation", "analysis", "rhythm", "lyrics", "mix"
}) # 要执行的模块集合
dry_run: bool = False # 仅分析,不执行创作模块
# ——— 元信息 ———
task_id: str = ""
created_at: str = ""
metadata: dict = field(default_factory=dict)
# ---------------------------------------------------------------------------
# 收集器
# ---------------------------------------------------------------------------
class AudioCollector:
"""输入收集器 — 接受文件/URL/命令 → AudioTask"""
SUPPORTED_FORMATS = {".mp3", ".wav", ".flac", ".m4a", ".aac", ".ogg", ".wma"}
def __init__(self, work_dir: str | Path = "./work/audio_line"):
self.work_dir = Path(work_dir)
self.work_dir.mkdir(parents=True, exist_ok=True)
self._upload_counter = 0
# ---- 公开 API ----
def from_file(self, path: str | Path, task_kwargs: dict | None = None) -> AudioTask:
"""从本地文件创建任务"""
path = Path(path)
if not path.exists():
raise FileNotFoundError(f"音频文件不存在: {path}")
if path.suffix.lower() not in self.SUPPORTED_FORMATS:
raise ValueError(f"不支持的格式 '{path.suffix}', 支持: {self.SUPPORTED_FORMATS}")
source = AudioSource(
path=str(path.resolve()),
original_filename=path.name,
source_type="file",
)
return self._build_task(source, task_kwargs)
def from_url(self, url: str, task_kwargs: dict | None = None) -> AudioTask:
"""从 URL 下载并创建任务"""
self._upload_counter += 1
ext = self._guess_ext_from_url(url)
dest = self.work_dir / f"download_{self._upload_counter}{ext}"
print(f" [collector] 下载: {url}")
urllib.request.urlretrieve(url, dest)
print(f" [collector] 已保存: {dest}")
source = AudioSource(
path=str(dest.resolve()),
original_filename=dest.name,
source_type="url",
)
return self._build_task(source, task_kwargs)
def from_text_command(self, text: str) -> AudioTask:
"""从自然语言指令创建任务(不含音频源,需后续绑定)"""
# 简单规则解析;生产环境可接入 LLM
from ._parser import parse_command_text
parsed = parse_command_text(text)
return parsed
# ---- 内部 ----
def _build_task(self, source: AudioSource, kwargs: dict | None = None) -> AudioTask:
kwargs = kwargs or {}
return AudioTask(
source=source,
operation=kwargs.pop("operation", "auto"),
target_bpm=kwargs.pop("target_bpm", 0),
target_key=kwargs.pop("target_key", ""),
style=kwargs.pop("style", ""),
topic=kwargs.pop("topic", ""),
duration=kwargs.pop("duration", 0.0),
modules=kwargs.pop("modules", {"separation", "analysis", "rhythm", "lyrics", "mix"}),
dry_run=kwargs.pop("dry_run", False),
metadata=kwargs,
)
@staticmethod
def _guess_ext_from_url(url: str) -> str:
path = urllib.parse.urlparse(url).path
ext = Path(path).suffix.lower()
return ext if ext in AudioCollector.SUPPORTED_FORMATS else ".mp3"
@@ -0,0 +1,180 @@
"""
layer2_analysis/beat_detector.py — 节拍/BPM 检测器
输入:原始音频路径
输出:BPM、节拍时间戳、重拍位置、节拍置信度
"""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import librosa
@dataclass
class BeatResult:
"""节拍分析结果"""
bpm: float # 检测到的 BPM
beats: list[float] = field(default_factory=list) # 节拍时间戳 (秒)
downbeats: list[float] = field(default_factory=list) # 重拍时间戳 (秒)
onset_frames: list[int] = field(default_factory=list) # 起音帧
onset_times: list[float] = field(default_factory=list) # 起音时间 (秒)
beat_frames: list[int] = field(default_factory=list) # 节拍帧索引
confidence: float = 0.0 # BPM 置信度 (0~1)
tempo_stability: float = 0.0 # 节奏稳定性 (0~1)
time_signature: str = "4/4" # 拍号推断
segment_bpms: list[dict] = field(default_factory=list) # [{start, end, bpm}, ...]
@property
def beat_count(self) -> int:
return len(self.beats)
@property
def duration_sec(self) -> float:
return self.beats[-1] if self.beats else 0.0
class BeatDetector:
"""Librosa 节拍检测器"""
def __init__(
self,
min_bpm: float = 60,
max_bpm: float = 200,
onset_backend: str = "librosa",
):
self.min_bpm = min_bpm
self.max_bpm = max_bpm
self.onset_backend = onset_backend
def detect(self, audio_path: str | Path) -> BeatResult:
"""
分析音频的 BPM / 节拍 / 重拍
参数:
audio_path: 音频文件路径
返回:
BeatResult 包含完整节拍分析
"""
audio_path = Path(audio_path)
print(f" [beat_det] 分析: {audio_path.name}")
# 加载音频 (librosa 自动重采样到 22050)
y, sr = librosa.load(str(audio_path), sr=None, mono=True)
# — 1. BPM 检测 —
tempo, beat_frames = librosa.beat.beat_track(
y=y, sr=sr,
min_bpm=self.min_bpm,
max_bpm=self.max_bpm,
units="frames",
)
bpm = float(tempo)
# — 2. 节拍时间戳 —
beat_times = librosa.frames_to_time(beat_frames, sr=sr).tolist()
# — 3. 起音检测 —
onset_frames = librosa.onset.onset_detect(y=y, sr=sr, units="frames")
onset_times = librosa.frames_to_time(onset_frames, sr=sr).tolist()
# — 4. 重拍检测 (使用 librosa 的 beat tracker 中的第一拍检测) —
# librosa 不直接提供 downbeat, 我们通过 beat 强度和周期性估算
# 方法:取每小节第一拍
beat_strength = self._estimate_beat_strength(y, sr, beat_frames)
downbeats = []
if len(beat_times) > 4:
# 取最强节拍作为小节的起始
# 近似: 假设 4/4 拍,每 4 拍一个重拍
period = self._estimate_bar_period(beat_times)
downbeats = beat_times[::period] if period > 0 else []
# — 5. 置信度 —
confidence = self._calc_confidence(y, sr, bpm, onset_frames)
# — 6. 节奏稳定性 —
stability = self._calc_stability(beat_times)
# — 7. 分段 BPM —
seg_bpms = self._segment_bpm(y, sr, beat_times)
print(f" [beat_det] ✓ BPM={bpm:.1f} 节拍={len(beat_times)} 重拍={len(downbeats)} "
f"置信度={confidence:.2f}")
return BeatResult(
bpm=bpm,
beats=beat_times,
downbeats=downbeats,
onset_frames=onset_frames.tolist(),
onset_times=onset_times,
beat_frames=beat_frames.tolist(),
confidence=confidence,
tempo_stability=stability,
segment_bpms=seg_bpms,
)
# ---- 内部方法 ----
def _estimate_beat_strength(self, y, sr, beat_frames) -> list[float]:
"""估算每个节拍的强度 (用频谱通量)"""
onset_env = librosa.onset.onset_strength(y=y, sr=sr)
strengths = []
for f in beat_frames:
idx = min(f, len(onset_env) - 1)
strengths.append(float(onset_env[idx]))
return strengths
def _estimate_bar_period(self, beat_times: list[float]) -> int:
"""估算每小节拍数"""
if len(beat_times) < 8:
return 4
# 最差选择 4 (4/4 拍最常见)
return 4
def _calc_confidence(self, y, sr, bpm, onset_frames) -> float:
"""BPM 置信度评估"""
# 更规则 → 更高置信度
if len(onset_frames) < 5:
return 0.3
# 简单启发式: 起音密度接近 BPM/60
duration = len(y) / sr
onset_density = len(onset_frames) / duration if duration > 0 else 0
expected_density = bpm / 60
ratio = min(onset_density / expected_density, 1.0) if expected_density > 0 else 0.5
return min(0.4 + 0.6 * ratio, 1.0)
def _calc_stability(self, beat_times: list[float]) -> float:
"""节奏稳定性: 节拍间隔的变异系数"""
if len(beat_times) < 4:
return 0.5
intervals = [beat_times[i+1] - beat_times[i] for i in range(len(beat_times)-1)]
mean_interval = sum(intervals) / len(intervals)
if mean_interval == 0:
return 0.0
variance = sum((i - mean_interval) ** 2 for i in intervals) / len(intervals)
cv = (variance ** 0.5) / mean_interval
return max(0.0, min(1.0, 1.0 - cv))
def _segment_bpm(self, y, sr, beat_times: list[float],
segment_len: float = 10.0) -> list[dict]:
"""按时间窗口分段分析 BPM"""
if len(beat_times) < 4:
return []
total_duration = len(y) / sr
segments = []
t = 0.0
while t < total_duration:
end = min(t + segment_len, total_duration)
seg_beats = [b for b in beat_times if t <= b < end]
if len(seg_beats) >= 4:
intervals = [seg_beats[i+1] - seg_beats[i] for i in range(len(seg_beats)-1)]
mean_int = sum(intervals) / len(intervals)
seg_bpm = 60.0 / mean_int if mean_int > 0 else 0
else:
seg_bpm = 0
segments.append({"start": round(t, 2), "end": round(end, 2), "bpm": round(seg_bpm, 1)})
t = end
return segments
@@ -0,0 +1,166 @@
"""
layer2_analysis/emotion_analyzer.py — 情绪/风格分析
输入:Stem 分离后的 vocals + 原曲
输出:情绪标签、风格描述、BPM 范围推荐
"""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import librosa
import numpy as np
@dataclass
class EmotionResult:
"""情绪分析结果"""
mood: str = "neutral" # 主要情绪 (happy/sad/energetic/calm/neutral)
energy: float = 0.5 # 能量值 0~1
valence: float = 0.5 # 效价 (积极度) 0~1
danceability: float = 0.5 # 舞曲性 0~1
acousticness: float = 0.5 # 原声性 0~1
tempo_category: str = "medium" # 速度分类: slow/medium/fast
description: str = "" # 自然语言风格描述
dominant_frequencies: dict = field(default_factory=dict) # 频率分布
spectral_features: dict = field(default_factory=dict) # 频谱特征
class EmotionAnalyzer:
"""
音频情绪分析器
基于频谱特征 + 节奏特征进行音乐情绪分类。
纯 librosa 实现,不依赖外部模型。
"""
def __init__(self):
self.mood_map: dict[str, tuple[float, float, float]] = {
# (energy_low, valence_low, dance_low) → label
"energetic": (0.6, 0.5, 0.6),
"happy": (0.4, 0.7, 0.5),
"calm": (0.2, 0.4, 0.2),
"sad": (0.2, 0.2, 0.2),
"aggressive": (0.8, 0.3, 0.4),
"neutral": (0.5, 0.5, 0.5),
}
def analyze(self, audio_path: str | Path, bpm: float = 0.0) -> EmotionResult:
"""
分析音频情绪
参数:
audio_path: 音频路径 (可以是原始音频或 vocals stem)
bpm: 可选的 BPM 值 (已检测到的)
返回:
EmotionResult
"""
audio_path = Path(audio_path)
print(f" [emotion] 分析: {audio_path.name}")
y, sr = librosa.load(str(audio_path), sr=None, mono=True)
# — 1. 频谱特征 —
spectral_centroids = librosa.feature.spectral_centroid(y=y, sr=sr)[0]
spectral_rolloff = librosa.feature.spectral_rolloff(y=y, sr=sr)[0]
spectral_bandwidth = librosa.feature.spectral_bandwidth(y=y, sr=sr)[0]
zero_crossing_rate = librosa.feature.zero_crossing_rate(y)[0]
rms = librosa.feature.rms(y=y)[0]
# — 2. 计算特征指标 —
avg_centroid = float(np.mean(spectral_centroids))
avg_rolloff = float(np.mean(spectral_rolloff))
avg_bandwidth = float(np.mean(spectral_bandwidth))
avg_zcr = float(np.mean(zero_crossing_rate))
avg_rms = float(np.mean(rms))
# — 3. 情绪分类 —
# energy: RMS + ZCR + centroid
energy = min(1.0, (avg_rms * 5 + avg_zcr * 2 + avg_centroid / 5000) / 3)
energy = max(0.0, min(1.0, energy))
# valence (积极度): ZCR 高 → 积极, centroid 高 → 明亮
valence = min(1.0, (avg_zcr * 3 + avg_centroid / 3000) / 2)
valence = max(0.0, min(1.0, valence))
# danceability: 节奏清晰度 (BPM 在 100-130 之间最可舞)
if bpm > 0:
dance = 1.0 - abs(bpm - 120) / 80
else:
dance = 0.5 - abs(energy - 0.5) * 0.3
danceability = max(0.0, min(1.0, dance))
# acousticness: 原声性 (low bandwidth + low centroid)
acousticness = max(0.0, 1.0 - (avg_bandwidth / 3000 + avg_centroid / 8000) / 2)
# — 4. 情绪标签 —
mood = self._classify_mood(energy, valence, danceability)
# — 5. 速度分类 —
if bpm > 0:
tempo_category = "fast" if bpm >= 120 else ("slow" if bpm <= 80 else "medium")
else:
tempo_category = "medium"
# — 6. 自然语言描述 —
description = self._describe(mood, energy, tempo_category)
# — 7. 频谱分布 —
dominant_freqs = self._dominant_frequencies(spectral_centroids)
spec_features = {
"avg_centroid_hz": round(avg_centroid, 1),
"avg_bandwidth_hz": round(avg_bandwidth, 1),
"avg_zcr": round(float(avg_zcr), 4),
"avg_rms": round(float(avg_rms), 4),
}
return EmotionResult(
mood=mood,
energy=round(energy, 3),
valence=round(valence, 3),
danceability=round(danceability, 3),
acousticness=round(acousticness, 3),
tempo_category=tempo_category,
description=description,
dominant_frequencies=dominant_freqs,
spectral_features=spec_features,
)
# ---- 内部 ----
def _classify_mood(self, energy: float, valence: float, dance: float) -> str:
"""基于能量/效价/舞曲性分类情绪"""
scores = {}
for label, (e_th, v_th, d_th) in self.mood_map.items():
score = (
(1.0 - abs(energy - e_th)) * 0.4 +
(1.0 - abs(valence - v_th)) * 0.4 +
(1.0 - abs(dance - d_th)) * 0.2
)
scores[label] = score
return max(scores, key=scores.get)
def _describe(self, mood: str, energy: float, tempo: str) -> str:
"""生成自然语言风格描述"""
descs = {
"energetic": f"充满活力的{tempo}速曲风,能量充沛",
"happy": f"明快愉悦的{tempo}速曲风,情绪积极",
"calm": f"舒缓平静的{tempo}速曲风,适合放松",
"sad": f"忧伤低沉的{tempo}速曲风,情绪内敛",
"aggressive": f"强劲有力的{tempo}速曲风,表现力强",
"neutral": f"中性均衡的{tempo}速曲风",
}
return descs.get(mood, f"{mood}风格的{tempo}速曲风")
def _dominant_frequencies(self, centroids: np.ndarray) -> dict:
"""分析频谱能量分布"""
mean_c = float(np.mean(centroids))
std_c = float(np.std(centroids))
return {
"mean_hz": round(mean_c, 1),
"std_hz": round(std_c, 1),
"range": f"{max(0, round(mean_c - std_c, 1))}-{round(mean_c + std_c, 1)} Hz",
}
@@ -0,0 +1,98 @@
"""
layer2_analysis/stem_separator.py — Demucs 音轨分离
输入:原始音频文件路径
输出:分离后的分轨字典 {vocals, drums, bass, other, ...}
"""
from __future__ import annotations
import os
import tempfile
from pathlib import Path
from typing import Literal
class StemSeparator:
"""Demucs 音轨分离器"""
STEM_NAMES = ["vocals", "drums", "bass", "other"]
MODEL_OPTIONS = ["htdemucs", "htdemucs_ft", "htdemucs_6s"]
def __init__(
self,
model: str = "htdemucs_ft",
device: str = "cpu",
shifts: int = 2,
overlap: float = 0.25,
segments: str = "default",
out_dir: str | Path | None = None,
):
if model not in self.MODEL_OPTIONS:
raise ValueError(f"不支持的模型 '{model}', 可选: {self.MODEL_OPTIONS}")
self.model = model
self.device = device
self.shifts = shifts
self.overlap = overlap
self.segments = segments
self.out_dir = Path(out_dir) if out_dir else Path(tempfile.mkdtemp(prefix="demucs_"))
def separate(self, audio_path: str | Path) -> dict[str, str]:
"""
执行音轨分离
参数:
audio_path: 输入音频路径
返回:
{stem_name: output_path, ...}
"""
audio_path = Path(audio_path)
if not audio_path.exists():
raise FileNotFoundError(f"音频文件不存在: {audio_path}")
print(f" [stem_sep] 模型={self.model} 设备={self.device}")
print(f" [stem_sep] 输入: {audio_path}")
# 构建 Demucs CLI 命令
output_dir = self.out_dir / "separated" / self.model
output_dir.mkdir(parents=True, exist_ok=True)
cmd = (
f"python3 -m demucs.separate"
f" --name {self.model}"
f" --device {self.device}"
f" --shifts {self.shifts}"
f" --overlap {self.overlap}"
f" -o {self.out_dir.resolve()}"
f" \"{audio_path.resolve()}\""
)
os.system(cmd)
# Demucs 输出: {out_dir}/{model}/{input_stem}/
input_stem = audio_path.stem
track_dir = output_dir / input_stem
# 收集分轨路径
stems = {}
for name in self.STEM_NAMES:
stem_path = track_dir / f"{name}.wav"
if stem_path.exists():
stems[name] = str(stem_path.resolve())
else:
# 尝试 .mp3
stem_path_mp3 = track_dir / f"{name}.mp3"
if stem_path_mp3.exists():
stems[name] = str(stem_path_mp3.resolve())
print(f" [stem_sep] ✓ 分离完成: {len(stems)} 轨")
for name, path in stems.items():
size_mb = Path(path).stat().st_size / 1_048_576
print(f" {name}: {Path(path).name} ({size_mb:.1f} MB)")
return stems
@classmethod
def available_models(cls) -> list[str]:
"""列出可用模型"""
return cls.MODEL_OPTIONS
@@ -0,0 +1,207 @@
"""
layer2_analysis/structure_parser.py — 歌曲结构解析
输入:原始音频 + 节拍分析结果
输出:段落标记 {intro, verse, chorus, bridge, outro, ...}
"""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import librosa
import numpy as np
@dataclass
class StructureResult:
"""歌曲结构解析结果"""
segments: list[dict] = field(default_factory=list) # [{label, start, end, confidence}]
section_count: int = 0
has_chorus: bool = False
has_bridge: bool = False
structure_summary: str = "" # 如 "Intro→Verse→Chorus→Verse→Chorus→Bridge→Outro"
@property
def section_labels(self) -> list[str]:
return [s["label"] for s in self.segments]
class StructureParser:
"""
歌曲结构解析器
基于频谱特征变化 + MFCC 聚类进行段落分割。
不依赖外部模型,纯 librosa 实现。
"""
SECTION_TAGS = {
0: "Intro", 1: "Verse", 2: "Chorus",
3: "Bridge", 4: "Outro", 5: "Instrumental",
}
def __init__(self, n_segments: int = 8, min_section_sec: float = 4.0):
self.n_segments = n_segments # 期望段落段数
self.min_section_sec = min_section_sec # 最短段落时长
def parse(self, audio_path: str | Path, beat_times: list[float] | None = None) -> StructureResult:
"""
解析歌曲结构
参数:
audio_path: 音频路径
beat_times: 可选,节拍时间戳用于辅助对齐
返回:
StructureResult
"""
audio_path = Path(audio_path)
print(f" [structure] 分析: {audio_path.name}")
y, sr = librosa.load(str(audio_path), sr=None, mono=True)
duration = len(y) / sr
# — 1. 提取 MFCC 序列 —
mfcc = librosa.feature.mfcc(y=y, sr=sr, n_mfcc=13, hop_length=512)
mfcc_delta = librosa.feature.delta(mfcc)
features = np.vstack([mfcc, mfcc_delta]) # (26, n_frames)
# — 2. 频谱特征 —
spectral_centroid = librosa.feature.spectral_centroid(y=y, sr=sr, hop_length=512)
spectral_contrast = librosa.feature.spectral_contrast(y=y, sr=sr, hop_length=512)
# 对齐到相同帧数
min_frames = min(features.shape[1], spectral_centroid.shape[1], spectral_contrast.shape[1])
features = features[:, :min_frames]
spec_feats = np.vstack([
spectral_centroid[:, :min_frames],
spectral_contrast[:, :min_frames],
])
all_features = np.vstack([features, spec_feats]) # (~33, n_frames)
# — 3. 计算自相似矩阵 + 结构边界 —
n_fft = all_features.shape[1]
# 用滑动窗口计算相似度变化
window = max(int(n_fft * 0.1), 4)
novelty = librosa.onset.onset_strength(
y=y, sr=sr, hop_length=512
)
# — 4. 频谱变化点检测 —
# 用 MFCC 的 novelty 曲线找变化点
mfcc_novelty = self._compute_novelty(mfcc)
change_points = self._find_peaks(mfcc_novelty, n_fft, self.n_segments)
# — 5. 节拍对齐 —
change_times = librosa.frames_to_time(change_points, sr=sr, hop_length=512)
if beat_times:
change_times = self._snap_to_beats(change_times, beat_times)
# — 6. 段落标记 —
segments = self._label_segments(change_times, duration, all_features)
# — 7. 结构分析 —
summary = "→".join(s["label"] for s in segments)
print(f" [structure] ✓ 结构: {summary}")
return StructureResult(
segments=segments,
section_count=len(segments),
has_chorus=any(s["label"] == "Chorus" for s in segments),
has_bridge=any(s["label"] == "Bridge" for s in segments),
structure_summary=summary,
)
# ---- 内部 ----
def _compute_novelty(self, features: np.ndarray, kernel_size: int = 3) -> np.ndarray:
"""计算 MFCC 的新颖性曲线"""
# 局部相似度矩阵的对角线核卷积
sim = librosa.segment.recurrence_matrix(features, mode="affinity")
kernel = np.zeros(kernel_size * 2 + 1)
kernel[:kernel_size] = -1
kernel[kernel_size] = 0
kernel[kernel_size + 1:] = 1
from scipy.ndimage import convolve1d
novelty = convolve1d(sim.mean(axis=0), kernel, mode="constant")
return novelty
def _find_peaks(self, novelty: np.ndarray, max_frames: int, n_peaks: int) -> np.ndarray:
"""找 novelty 曲线峰值作为段落边界"""
from scipy.signal import find_peaks
if len(novelty) == 0:
return np.array([0, max_frames - 1])
peaks, properties = find_peaks(novelty, distance=max_frames // max(n_peaks, 2))
if len(peaks) < 2:
return np.array([0, max_frames - 1])
# 取最强的 n_peaks 个边界
strengths = properties.get("peak_heights", np.ones(len(peaks)))
top_idx = np.argsort(strengths)[-n_peaks:]
top_peaks = np.sort(peaks[top_idx])
# 补首尾
return np.unique(np.concatenate([[0], top_peaks, [max_frames - 1]]))
def _snap_to_beats(self, times: list[float], beats: list[float]) -> list[float]:
"""将段落边界对齐到最近的节拍"""
snapped = []
for t in times:
nearest = min(beats, key=lambda b: abs(b - t)) if beats else t
snapped.append(nearest)
return snapped
def _label_segments(self, boundaries: list[float], duration: float,
features: np.ndarray) -> list[dict]:
"""为每个段落打标签"""
segments = []
for i in range(len(boundaries) - 1):
start = round(boundaries[i], 2)
end = round(boundaries[i + 1], 2)
if end - start < self.min_section_sec:
continue
# 用 MFCC 特征中心 + 能量判断标签
energy = self._segment_energy(features, boundaries, i)
label = self._infer_label(i, len(boundaries), energy)
segments.append({
"label": label,
"start": start,
"end": end,
"confidence": 0.7, # 固定置信度,后续可改进
})
if not segments:
segments.append({"label": "Full", "start": 0.0, "end": round(duration, 2),
"confidence": 1.0})
return segments
def _segment_energy(self, features: np.ndarray, boundaries: list[float],
idx: int) -> float:
"""估算段落能量"""
# 简化为基于索引位置的启发式
total = len(boundaries) - 1
# Chorus 通常在中间偏后,能量最高
center = total / 2
distance_from_center = abs(idx - center)
energy = 1.0 - (distance_from_center / max(center, 1)) * 0.5
return max(0.3, energy)
def _infer_label(self, idx: int, total: int, energy: float) -> str:
"""基于位置启发式推断段落标签"""
if total <= 2:
return "Full"
if idx == 0:
return "Intro"
if idx == total - 2:
return "Bridge" if total > 4 else "Outro"
if idx == total - 1:
return "Outro"
# Chorus 在中间偏后位置
mid = total / 2
if abs(idx - mid) < 1.5 and energy > 0.6:
return "Chorus"
return "Verse"
@@ -0,0 +1,12 @@
"""AudioLine 创作层 — 节奏/歌词/旋律/混音"""
from .module_a_rhythm import RhythmModule, RhythmResult
from .module_b_lyrics import LyricsModule, LyricsResult
from .module_c_melody import MelodyModule, MelodyResult
from .module_d_mix import MixModule, MixResult
__all__ = [
"RhythmModule", "RhythmResult",
"LyricsModule", "LyricsResult",
"MelodyModule", "MelodyResult",
"MixModule", "MixResult",
]
@@ -0,0 +1,227 @@
"""
layer3_creation/module_a_rhythm.py — 节奏重塑模块
功能:基于 Mubert/Mubert-like API 生成节奏/鼓点轨道
卡点改编的核心:原曲鼓点替换 → BPM 匹配 → 节奏增强
"""
from __future__ import annotations
import json
import os
import time
import urllib.error
import urllib.request
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from lib.http import urlopen as _noproxy_urlopen # 禁代理+重试
@dataclass
class RhythmResult:
"""节奏生成结果"""
drum_track_path: str = "" # 生成的鼓点音频路径
bpm: float = 0.0 # 实际 BPM
genre: str = "" # 使用的风格
duration: float = 0.0 # 时长 (秒)
api_used: str = "" # 使用的 API
cost: float = 0.0 # API 调用成本
class RhythmModule:
"""
节奏重塑模块
支持两种模式:
1. Mubert API — 在线生成高质量鼓点 (需要 API key)
2. 本地替代 — 用 librosa 节拍合成简单鼓点 (免费)
"""
def __init__(self, config: dict | None = None):
self.config = config or {}
self.api_key = self.config.get("mubert", {}).get("api_key", "")
self.base_url = self.config.get("mubert", {}).get(
"base_url", "https://api.mubert.com/v2"
)
self.default_genre = self.config.get("mubert", {}).get(
"default_genre", "electronic"
)
self.default_duration = self.config.get("mubert", {}).get(
"default_duration", 30
)
self.timeout = self.config.get("mubert", {}).get("timeout", 60)
def generate(
self,
bpm: float,
genre: str = "",
duration: float = 0.0,
output_path: str | Path | None = None,
) -> RhythmResult:
"""
生成节奏/鼓点轨道
参数:
bpm: 目标 BPM
genre: 风格 (electronic/house/techno/hiphop/pop/rock)
duration: 目标时长 (秒), 0=默认
output_path: 输出路径
返回:
RhythmResult
"""
genre = genre or self.default_genre
duration = duration if duration > 0 else self.default_duration
output_path = Path(output_path or f"./output/rhythm_{int(bpm)}_{genre}.wav")
output_path.parent.mkdir(parents=True, exist_ok=True)
print(f" [rhythm] 目标: BPM={bpm} 风格={genre} 时长={duration}s")
if self.api_key:
result = self._via_mubert(bpm, genre, duration, output_path)
else:
print(f" [rhythm] 无 Mubert API key, 使用本地合成替代")
result = self._local_fallback(bpm, duration, output_path)
if output_path.exists():
result.drum_track_path = str(output_path.resolve())
size_mb = output_path.stat().st_size / 1_048_576
print(f" [rhythm] ✓ 输出: {output_path.name} ({size_mb:.1f} MB)")
else:
print(f" [rhythm] ⚠ 输出文件不存在")
return result
# ---- Mubert API ----
def _via_mubert(
self, bpm: float, genre: str, duration: float, output_path: Path
) -> RhythmResult:
"""通过 Mubert API 生成"""
result = RhythmResult(bpm=bpm, genre=genre, duration=duration, api_used="mubert")
try:
# Mubert API: POST /v2/track/render
payload = json.dumps({
"pat": self.api_key,
"bpm": int(bpm),
"genre": genre,
"duration": int(duration),
"format": "wav",
}).encode("utf-8")
req = urllib.request.Request(
f"{self.base_url}/track/render",
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
with _noproxy_urlopen(req, timeout=self.timeout) as resp:
data = json.loads(resp.read().decode("utf-8"))
# 下载生成的音频
if "data" in data and "url" in data["data"]:
audio_url = data["data"]["url"]
urllib.request.urlretrieve(audio_url, output_path)
result.cost = data.get("data", {}).get("cost", 0.0)
except (urllib.error.HTTPError, urllib.error.URLError, json.JSONDecodeError) as e:
print(f" [rhythm] Mubert API 请求失败: {e}")
print(f" [rhythm] 回退到本地合成")
return self._local_fallback(bpm, duration, output_path)
return result
# ---- 本地替代方案 ----
def _local_fallback(
self, bpm: float, duration: float, output_path: Path
) -> RhythmResult:
"""
本地合成简单鼓点节奏
用 librosa 的 clicks 功能生成节拍点击声,
配合简单的频谱叠加模拟 kick + snare + hi-hat。
"""
import librosa
import numpy as np
import soundfile as sf
sr = 22050
total_samples = int(duration * sr)
beat_interval = int(60.0 / bpm * sr) # 每拍采样数
half_beat = beat_interval // 2
# — 构建鼓点模式 (4/4 拍: kick on 1,3, snare on 2,4, hi-hat 8th notes) —
audio = np.zeros(total_samples, dtype=np.float32)
for beat_idx in range(int(duration * bpm / 60) + 1):
pos = beat_idx * beat_interval
if pos >= total_samples:
break
beat_in_bar = beat_idx % 4
# Kick: beats 0, 2 (1st and 3rd beat)
if beat_in_bar in (0, 2):
kick = self._make_kick(sr, int(beat_interval * 0.4))
end = min(pos + len(kick), total_samples)
audio[pos:end] += kick[:end - pos] * 1.0
# Snare: beats 1, 3 (2nd and 4th beat)
if beat_in_bar in (1, 3):
snare = self._make_snare(sr, int(beat_interval * 0.3))
end = min(pos + len(snare), total_samples)
audio[pos:end] += snare[:end - pos] * 0.8
# Hi-hat: every half beat
for off in (0, half_beat):
hh_pos = pos + off
if hh_pos < total_samples:
hh = self._make_hihat(sr, int(beat_interval * 0.15))
end = min(hh_pos + len(hh), total_samples)
audio[hh_pos:end] += hh[:end - hh_pos] * 0.3
# 归一化
peak = np.max(np.abs(audio))
if peak > 0:
audio = audio / peak * 0.9
sf.write(str(output_path), audio, sr)
return RhythmResult(
bpm=bpm,
genre="local_fallback",
duration=duration,
drum_track_path=str(output_path.resolve()),
api_used="local",
)
@staticmethod
def _make_kick(sr: int, length: int) -> np.ndarray:
"""合成 kick 鼓声"""
import numpy as np
t = np.arange(length) / sr
# 频率指数衰减 150→40Hz
freq = 150 * np.exp(-t * 25)
phase = 2 * np.pi * np.cumsum(freq) / sr
return np.sin(phase) * np.exp(-t * 30)
@staticmethod
def _make_snare(sr: int, length: int) -> np.ndarray:
"""合成 snare 鼓声"""
import numpy as np
t = np.arange(length) / sr
tone = np.sin(2 * np.pi * 200 * t)
noise = np.random.randn(length) * 0.5
return (tone + noise) * np.exp(-t * 20)
@staticmethod
def _make_hihat(sr: int, length: int) -> np.ndarray:
"""合成 hi-hat 声"""
import numpy as np
t = np.arange(length) / sr
noise = np.random.randn(length)
# 高通滤波
filtered = noise * np.exp(-t * 60)
return filtered * 0.5
@@ -0,0 +1,174 @@
"""
layer3_creation/module_b_lyrics.py — 歌词创作模块
功能:基于 oMLX 本地 LLM 进行歌词创作
核心场景:换词 (rewrite) — 保持原曲结构,替换内容
"""
from __future__ import annotations
import json
import urllib.error
import urllib.request
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from lib.http import urlopen as _noproxy_urlopen # 禁代理+重试
@dataclass
class LyricsResult:
"""歌词创作结果"""
original_lyrics: str = "" # 原歌词
rewritten_lyrics: str = "" # 改写后歌词
structure: list[dict] = field(default_factory=list) # [{section, lines}]
analysis: dict = field(default_factory=dict) # 段落分析 JSON
model_used: str = "" # 使用的模型
token_count: int = 0 # 消耗 token 数
class LyricsModule:
"""
歌词创作模块
使用 oMLX 本地 LLM 进行:
- 歌词分析 (结构/押韵/情感)
- 歌词改写 (换词创作)
- 风格迁移
"""
DEFAULT_PROMPTS = {
"rewrite": (
"你是一位专业歌词改编者。请根据以下要求对原曲歌词进行二次创作:\n"
"主题: {topic}\n风格: {style}\n"
"保留原曲的段落结构(Intro/Verse/Chorus/Bridge/Outro)和押韵模式。\n"
"输出格式: 每段以[Intro][Verse][Chorus]标记开头。"
),
"analyze": (
"分析以下歌词的段落结构、押韵模式和情感走向。\n"
"输出 JSON 格式,包含: sections, rhyme_scheme, emotion_flow。"
),
}
def __init__(self, config: dict | None = None):
self.config = config or {}
self.llm_url = self.config.get("lyrics", {}).get(
"llm_url", "http://localhost:8000/v1"
)
self.llm_model = self.config.get("lyrics", {}).get(
"llm_model", "Qwen3-8B"
)
self.max_tokens = self.config.get("lyrics", {}).get("max_tokens", 1024)
self.temperature = self.config.get("lyrics", {}).get("temperature", 0.8)
self.rewrite_template = self.config.get(
"lyrics", {}
).get("rewrite_prompt", self.DEFAULT_PROMPTS["rewrite"])
def rewrite(
self,
original_lyrics: str,
topic: str = "",
style: str = "",
output_path: str | Path | None = None,
) -> LyricsResult:
"""
歌词改写
参数:
original_lyrics: 原歌词文本
topic: 新主题
style: 目标风格
output_path: 输出路径 (可选)
返回:
LyricsResult
"""
print(f" [lyrics] 改写创作: topic='{topic}' style='{style}'")
prompt = self.rewrite_template.format(topic=topic or "相同主题", style=style or "原风格")
messages = [
{"role": "system", "content": "你是专业歌词作家。只输出歌词内容,不要额外解释。"},
{"role": "user", "content": f"{prompt}\n\n原歌词:\n{original_lyrics}"},
]
rewritten = self._call_llm(messages)
result = LyricsResult(
original_lyrics=original_lyrics,
rewritten_lyrics=rewritten,
model_used=self.llm_model,
)
if output_path:
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "w", encoding="utf-8") as f:
f.write(rewritten)
print(f" [lyrics] ✓ 已保存: {output_path}")
print(f" [lyrics] ✓ 改写完成 ({len(rewritten)} 字符)")
return result
def analyze(self, lyrics: str) -> LyricsResult:
"""
分析歌词结构
返回 JSON 格式的段落分析
"""
print(f" [lyrics] 分析结构")
messages = [
{"role": "system", "content": "你是一个歌词分析专家。只输出 JSON。"},
{"role": "user", "content": f"{self.DEFAULT_PROMPTS['analyze']}\n\n歌词:\n{lyrics}"},
]
response = self._call_llm(messages)
analysis = {}
try:
# 尝试解析 JSON
start = response.find("{")
end = response.rfind("}") + 1
if start >= 0 and end > start:
analysis = json.loads(response[start:end])
except (json.JSONDecodeError, ValueError):
analysis = {"raw": response}
return LyricsResult(
original_lyrics=lyrics,
analysis=analysis,
model_used=self.llm_model,
)
# ---- LLM 调用 ----
def _call_llm(self, messages: list[dict]) -> str:
"""调用 oMLX API"""
payload = json.dumps({
"model": self.llm_model,
"messages": messages,
"max_tokens": self.max_tokens,
"temperature": self.temperature,
}).encode("utf-8")
try:
req = urllib.request.Request(
f"{self.llm_url}/chat/completions",
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
with _noproxy_urlopen(req, timeout=30) as resp:
data = json.loads(resp.read().decode("utf-8"))
content = data["choices"][0]["message"]["content"]
self._last_tokens = data.get("usage", {}).get("total_tokens", 0)
return content
except (urllib.error.HTTPError, urllib.error.URLError,
json.JSONDecodeError, KeyError) as e:
print(f" [lyrics] oMLX API 调用失败: {e}")
print(f" [lyrics] 请确保 oMLX Server 已启动 (localhost:8000)")
return ""
@property
def last_tokens(self) -> int:
"""上次调用的 token 数"""
return getattr(self, "_last_tokens", 0)
@@ -0,0 +1,135 @@
"""
layer3_creation/module_c_melody.py — 旋律编曲模块
功能:基于 Suno API 进行旋律改编/Remix
核心场景:曲风迁移、旋律重编、Remix
"""
from __future__ import annotations
import json
import os
import time
import urllib.error
import urllib.request
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from lib.http import urlopen as _noproxy_urlopen # 禁代理+重试
@dataclass
class MelodyResult:
"""旋律编曲结果"""
output_path: str = ""
style: str = ""
duration: float = 0.0
api_used: str = ""
cost: float = 0.0
task_id: str = ""
class MelodyModule:
"""
旋律编曲模块
使用 Suno API 进行旋律创作/Remix。
注意: 需要有效的 Suno API key 才能使用在线服务。
无 key 时回退到本地音频拼接 (仅保留结构,不生成新旋律)。
"""
def __init__(self, config: dict | None = None):
self.config = config or {}
self.api_key = self.config.get("suno", {}).get("api_key", "")
self.base_url = self.config.get("suno", {}).get(
"base_url", "https://api.suno.ai/v1"
)
self.model = self.config.get("suno", {}).get("model", "suno-v4")
self.timeout = self.config.get("suno", {}).get("timeout", 120)
self.max_retries = self.config.get("suno", {}).get("max_retries", 2)
def remix(
self,
audio_path: str | Path,
target_style: str = "",
duration: float = 0.0,
output_path: str | Path | None = None,
) -> MelodyResult:
"""
旋律改编
参数:
audio_path: 原曲音频路径
target_style: 目标风格 (如 "jazz", "electronic", "orchestral")
duration: 目标时长
output_path: 输出路径
返回:
MelodyResult
"""
audio_path = Path(audio_path)
output_path = Path(
output_path or f"./output/melody_{audio_path.stem}_{target_style or 'remix'}.wav"
)
output_path.parent.mkdir(parents=True, exist_ok=True)
print(f" [melody] 编曲: style='{target_style}' duration={duration}s")
if self.api_key:
return self._via_suno(audio_path, target_style, duration, output_path)
else:
print(f" [melody] 无 Suno API key, 跳过旋律编曲")
print(f" [melody] 提示: module_c_melody 需要 Suno API key 才能工作")
return MelodyResult(
output_path=str(output_path) if output_path.exists() else "",
style=target_style,
api_used="none",
)
def _via_suno(
self, audio_path: Path, style: str, duration: float, output_path: Path
) -> MelodyResult:
"""通过 Suno API 进行 Remix"""
for attempt in range(self.max_retries):
try:
# Suno API: POST /v1/generate/remix
payload = json.dumps({
"model": self.model,
"audio_url": str(audio_path.resolve()),
"style": style or "pop",
"duration": int(duration) if duration > 0 else 30,
"format": "wav",
}).encode("utf-8")
req = urllib.request.Request(
f"{self.base_url}/generate/remix",
data=payload,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {self.api_key}",
},
method="POST",
)
with _noproxy_urlopen(req, timeout=self.timeout) as resp:
data = json.loads(resp.read().decode("utf-8"))
# 下载生成的音频
audio_url = data.get("data", {}).get("url", "")
if audio_url:
urllib.request.urlretrieve(audio_url, output_path)
return MelodyResult(
output_path=str(output_path.resolve()) if output_path.exists() else "",
style=style,
duration=duration,
api_used="suno",
task_id=data.get("data", {}).get("id", ""),
)
except (urllib.error.HTTPError, urllib.error.URLError,
json.JSONDecodeError) as e:
print(f" [melody] Suno API 失败 (尝试 {attempt+1}/{self.max_retries}): {e}")
if attempt < self.max_retries - 1:
time.sleep(2)
return MelodyResult(style=style, api_used="suno_failed")
@@ -0,0 +1,291 @@
"""
layer3_creation/module_d_mix.py — 混音母带模块
功能:将多轨音频(vocals/drums/bass/other)混音为完整立体声
支持 EQ / 压缩 / 限制器 / 混响 / 响度归一化
基于 Pedalboard (Spotify 开源音频处理库)。
回退方案:无 Pedalboard 时使用 pydub + numpy 基本叠加。
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import numpy as np
@dataclass
class MixResult:
"""混音母带结果"""
master_path: str = "" # 混音后主轨路径
stem_paths: dict[str, str] = field(default_factory=dict) # 处理后分轨路径
loudness: float = 0.0 # 最终 LUFS 响度
peak_dB: float = 0.0 # 峰值 (dB)
headroom: float = 0.0 # 头上空间 (dB)
duration: float = 0.0 # 时长 (秒)
engine: str = "" # 使用的引擎 (pedalboard | pydub | numpy)
class MixModule:
"""
混音母带模块
处理流程:
1. 加载各分轨音频
2. 电平平衡 (Volume/gain staging)
3. EQ 均衡 (可选预设)
4. 压缩 (可选)
5. 混响 (可选)
6. 立体声叠加
7. 峰值限制 → 响度归一化
8. 导出
"""
def __init__(self, config: dict | None = None):
self.config = config or {}
mix_cfg = config.get("mixing", {}) if config else {}
self.target_loudness = float(mix_cfg.get("target_loudness", -14.0))
self.headroom_target = float(mix_cfg.get("headroom", 1.0))
self.sample_rate = int(mix_cfg.get("sample_rate", 44100))
self.bit_depth = int(mix_cfg.get("bit_depth", 16))
self.eq_preset = mix_cfg.get("eq_preset", "default")
self.compressor_threshold = float(mix_cfg.get("compressor_threshold", -20))
self.compressor_ratio = float(mix_cfg.get("compressor_ratio", 3.0))
self.compressor_attack = float(mix_cfg.get("compressor_attack", 5))
self.compressor_release = float(mix_cfg.get("compressor_release", 100))
self._pedalboard = None
self._has_pedalboard = False
self._init_pedalboard()
def _init_pedalboard(self):
"""尝试加载 Pedalboard"""
try:
import pedalboard
self._pedalboard = pedalboard
self._has_pedalboard = True
except ImportError:
self._has_pedalboard = False
def mix(
self,
stems: dict[str, str],
output_path: str | Path = "./output/mix/master.wav",
stem_levels: dict[str, float] | None = None,
apply_eq: bool = True,
apply_compressor: bool = True,
apply_reverb: bool = False,
include_stems: bool = True,
) -> MixResult:
"""
混音主入口
参数:
stems: {stem_name: audio_path, ...}
output_path: 混音输出路径
stem_levels: 分轨电平增益 {name: dB},默认 ±0
apply_eq: 是否应用 EQ
apply_compressor: 是否应用压缩
apply_reverb: 是否应用混响
include_stems: 是否同时导出处理后的分轨
返回:
MixResult
"""
output_path = Path(output_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
print(f" [mix] 混音开始: {len(stems)} 轨")
stem_levels = stem_levels or {}
processed_stems: dict[str, Path] = {}
stem_audio: dict[str, np.ndarray] = {}
ref_sr = self.sample_rate
# — 1. 加载 + 处理各分轨 —
for name, audio_path in stems.items():
if not Path(audio_path).exists():
print(f" [mix] ⚠ 分轨不存在: {audio_path}, 跳过")
continue
y, sr = self._load_audio(audio_path, ref_sr)
gain_db = stem_levels.get(name, 0.0)
# 应用增益
if gain_db != 0:
y = y * (10 ** (gain_db / 20))
# 应用处理 (EQ/压缩)
if self._has_pedalboard:
y = self._apply_processing(
y, sr, name, apply_eq, apply_compressor, apply_reverb
)
stem_audio[name] = y
# 导出处理后分轨
if include_stems:
stem_out = output_path.parent / f"processed_{name}.wav"
self._save_audio(stem_out, y, sr)
processed_stems[name] = str(stem_out.resolve())
print(f" [mix] {name}: {gain_db:+.1f}dB {len(y)/sr:.1f}s")
if not stem_audio:
raise ValueError("没有可用的分轨进行混音")
# — 2. 立体声叠加 —
ref_sr = self._get_common_sr(stem_audio)
master = self._sum_stems(stem_audio)
# — 3. 峰值限制 + 响度归一化 —
master, peak_db, loudness = self._master_bus(master, ref_sr)
# — 4. 导出 —
self._save_audio(output_path, master, ref_sr)
headroom = abs(peak_db) if peak_db < 0 else 0
result = MixResult(
master_path=str(output_path.resolve()),
stem_paths={k: str(v) for k, v in processed_stems.items()},
loudness=round(loudness, 2),
peak_dB=round(peak_db, 2),
headroom=round(headroom, 2),
duration=round(len(master) / ref_sr, 2),
engine="pedalboard" if self._has_pedalboard else "numpy",
)
print(f" [mix] ✓ 混音完成: {output_path.name}")
print(f" [mix] 响度={loudness:.1f} LUFS 峰值={peak_db:.1f} dBFS "
f"头上空间={headroom:.1f} dB")
return result
# ---- 内部处理 ----
def _load_audio(self, path: str | Path, target_sr: int) -> tuple[np.ndarray, int]:
"""加载音频文件"""
import librosa
y, sr = librosa.load(str(path), sr=target_sr, mono=False)
# 确保为 2D: (channels, samples)
if y.ndim == 1:
y = np.stack([y, y]) # mono → stereo
return y, sr
def _get_common_sr(self, stem_audio: dict) -> int:
"""取所有分轨的共用采样率"""
return self.sample_rate
def _apply_processing(
self,
y: np.ndarray,
sr: int,
stem_name: str,
apply_eq: bool,
apply_compressor: bool,
apply_reverb: bool,
) -> np.ndarray:
"""Pedalboard 处理链"""
import pedalboard as pb
chain = []
# EQ
if apply_eq:
chain.append(
pb.Filterband(
cutoff_frequency=80.0,
filter_type="highpass",
)
)
if stem_name == "vocals":
# 人声: 提升中频 (presence)
chain.append(pb.Equalizer(num_eq_bands=3))
# 压缩
if apply_compressor and stem_name in ("vocals", "bass"):
chain.append(
pb.Compressor(
threshold_db=self.compressor_threshold,
ratio=self.compressor_ratio,
attack_ms=self.compressor_attack,
release_ms=self.compressor_release,
)
)
# 混响 (仅应用到 vocal/master)
if apply_reverb and stem_name == "vocals":
chain.append(pb.Reverb(room_size=0.3, damping=0.5))
if not chain:
return y
board = pb.Pedalboard(chain, sample_rate=sr)
return board.process(y)
def _sum_stems(self, stem_audio: dict[str, np.ndarray]) -> np.ndarray:
"""叠加多轨音频"""
# 找出最长轨道
max_len = max(y.shape[1] for y in stem_audio.values())
master = np.zeros((2, max_len), dtype=np.float64)
for name, y in stem_audio.items():
y_len = y.shape[1]
if y_len < max_len:
pad = np.zeros((2, max_len - y_len))
master[:, :y_len] += y[:, :] * 0.8
master[:, y_len:] += pad
else:
master += y * 0.8
# 防止削波前先归一化
peak = np.max(np.abs(master))
if peak > 0.95:
master = master / peak * 0.95
return master.astype(np.float32)
def _master_bus(
self, audio: np.ndarray, sr: int
) -> tuple[np.ndarray, float, float]:
"""母带总线: 限制器 + 响度归一化"""
if self._has_pedalboard:
import pedalboard as pb
# 限制器
limiter = pb.Limiter(
threshold_db=-self.headroom_target,
release_ms=100,
)
board = pb.Pedalboard([limiter], sample_rate=sr)
audio = board.process(audio)
# 测量峰值
peak_db = float(20 * np.log10(np.max(np.abs(audio)) + 1e-10))
# 响度归一化 (简单 RMS 归一化,完整 LUFS 需要 pyloudnorm)
rms = np.sqrt(np.mean(audio ** 2))
if rms > 0:
target_rms = 10 ** (self.target_loudness / 20)
gain = target_rms / rms
audio = audio * min(gain, 2.0) # 限制最大增益 6dB
loudness = self.target_loudness # 近似
return audio, peak_db, loudness
def _save_audio(self, path: Path, audio: np.ndarray, sr: int):
"""保存音频"""
import soundfile as sf
# soundfile 期望 (samples, channels)
if audio.ndim == 2 and audio.shape[0] <= 8:
audio = audio.T
sf.write(str(path), audio, sr, subtype="PCM_16")
@@ -0,0 +1,8 @@
"""AudioLine 对齐层 — 歌词节拍对齐 / 分轨时间对齐"""
from .lyric_aligner import LyricAligner, LyricTimeline
from .stem_aligner import StemAligner, AlignResult
__all__ = [
"LyricAligner", "LyricTimeline",
"StemAligner", "AlignResult",
]
@@ -0,0 +1,309 @@
"""
layer4_alignment/lyric_aligner.py — 歌词-节拍对齐模块
功能:将歌词文本按段落结构对齐到节拍时间戳。
输出格式:[{start, end, text, section}] 时间轴,支持转 LRC/SRT。
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
@dataclass
class LyricTimeline:
"""歌词时间轴"""
entries: list[dict] = field(default_factory=list) # [{start, end, text, section}]
total_duration: float = 0.0
sections: list[dict] = field(default_factory=list) # [{label, start, end}]
@property
def lyric_count(self) -> int:
return len(self.entries)
def to_lrc(self) -> str:
"""转为 LRC 歌词格式"""
lines = ["[ti:AudioLine LRC]", "[offset:0]"]
for entry in self.entries:
mm, ss = divmod(entry["start"], 60)
cs = int((entry["start"] - int(entry["start"])) * 100)
lines.append(f"[{int(mm):02d}:{int(ss):02d}.{cs:02d}]{entry['text']}")
return "\n".join(lines)
def to_srt(self) -> str:
"""转为 SRT 字幕格式"""
blocks = []
for i, entry in enumerate(self.entries, 1):
start_srt = self._sec_to_srt(entry["start"])
end_srt = self._sec_to_srt(entry["end"])
blocks.append(f"{i}\n{start_srt} --> {end_srt}\n{entry['text']}\n")
return "\n".join(blocks)
@staticmethod
def _sec_to_srt(sec: float) -> str:
h, m = divmod(sec, 3600)
m, s = divmod(m, 60)
cs = int((s - int(s)) * 1000)
return f"{int(h):02d}:{int(m):02d}:{int(s):02d},{cs:03d}"
class LyricAligner:
"""
歌词-节拍对齐器
将结构化的歌词段落匹配到分析出的节拍/段落边界。
支持两种模式:
1. 自动对齐 — 根据段落标签映射
2. 手动指定 — 提供精确的时间偏移
"""
def __init__(self, tolerance: float = 0.1):
self.tolerance = tolerance # 时间容差 (秒)
def align(
self,
lyrics: str,
beats: list[float],
sections: list[dict] | None = None,
total_duration: float = 0.0,
) -> LyricTimeline:
"""
将对齐歌词到节拍
参数:
lyrics: 歌词文本 (含 [Intro][Verse] 等段落标记)
beats: 节拍时间戳列表 (秒)
sections: 段落结构 [{label, start, end}] (可选)
total_duration: 总时长 (秒)
返回:
LyricTimeline
"""
# — 1. 解析歌词段落 —
parsed_sections = self._parse_lyric_sections(lyrics)
if sections:
# — 2a. 使用已分析的段落边界 —
timeline = self._align_with_sections(
parsed_sections, beats, sections, total_duration
)
else:
# — 2b. 仅用节拍均匀分配 —
timeline = self._align_by_beats(parsed_sections, beats, total_duration)
return timeline
def align_lines(
self,
lines: list[str],
section: str,
section_start: float,
section_end: float,
beats_in_section: list[float],
) -> list[dict]:
"""
对齐单个段落内的歌词行到节拍
参数:
lines: 歌词行列表
section: 段落标签
section_start: 段落开始时间
section_end: 段落结束时间
beats_in_section: 段落内的节拍
返回:
[{start, end, text, section}]
"""
if not lines:
return []
if not beats_in_section:
# 无节拍: 均匀分配
return self._distribute_evenly(lines, section, section_start, section_end)
entries = []
beats_per_line = max(len(beats_in_section) // len(lines), 1)
for i, line in enumerate(lines):
start_idx = i * beats_per_line
end_idx = min((i + 1) * beats_per_line, len(beats_in_section))
start = beats_in_section[start_idx] if start_idx < len(beats_in_section) else section_start
end = beats_in_section[end_idx - 1] if end_idx <= len(beats_in_section) else section_end
# 对最后一行: 用段落结束而不是最后一个节拍
if i == len(lines) - 1:
end = section_end
entries.append({
"start": round(start, 3),
"end": round(end, 3),
"text": line.strip(),
"section": section,
})
return entries
# ---- 内部 ----
def _parse_lyric_sections(self, lyrics: str) -> list[dict[str, Any]]:
"""解析歌词中的段落标记"""
sections = []
# 匹配 [Intro], [Verse 1], [Chorus] 等标记
pattern = r"\[(\w+(?:\s*\d*)?)\](.*?)(?=\[\w|$)"
matches = re.findall(pattern, lyrics, re.DOTALL)
if not matches:
# 无标记: 整段视为一个段落
lines = [l for l in lyrics.strip().split("\n") if l.strip()]
sections.append({"label": "Full", "lines": lines})
else:
for label, content in matches:
lines = [l.strip() for l in content.strip().split("\n") if l.strip()]
if lines:
sections.append({"label": label.strip(), "lines": lines})
return sections
def _align_with_sections(
self,
parsed: list[dict],
beats: list[float],
sections: list[dict],
total_duration: float,
) -> LyricTimeline:
"""基于段落结构对齐"""
all_entries = []
for ps in parsed:
label = ps["label"]
# 找对应的段落
section = next(
(s for s in sections if label.lower() in s.get("label", "").lower()),
None,
)
if section:
start = section["start"]
end = section["end"]
beats_in_sec = [b for b in beats if start <= b <= end]
else:
# 找不到: 用标签顺序估算位置
idx = parsed.index(ps)
total = len(parsed)
start = (idx / total) * total_duration
end = ((idx + 1) / total) * total_duration
beats_in_sec = [b for b in beats if start <= b <= end]
entries = self.align_lines(
ps["lines"], label, start, end, beats_in_sec
)
all_entries.extend(entries)
return LyricTimeline(
entries=all_entries,
total_duration=total_duration or (all_entries[-1]["end"] if all_entries else 0),
sections=sections,
)
def _align_by_beats(
self,
parsed: list[dict],
beats: list[float],
total_duration: float,
) -> LyricTimeline:
"""仅基于节拍均匀分配"""
if not beats:
return self._fallback_linear(parsed, total_duration)
all_entries = []
total_lines = sum(len(ps["lines"]) for ps in parsed)
if total_lines == 0:
return LyricTimeline()
beats_per_line = max(len(beats) // total_lines, 1)
line_idx = 0
for ps in parsed:
for line in ps["lines"]:
start_idx = line_idx * beats_per_line
end_idx = min((line_idx + 1) * beats_per_line, len(beats))
start = beats[start_idx] if start_idx < len(beats) else 0
end = beats[end_idx - 1] if end_idx <= len(beats) else (total_duration or end + 2)
all_entries.append({
"start": round(start, 3),
"end": round(end, 3),
"text": line.strip(),
"section": ps["label"],
})
line_idx += 1
return LyricTimeline(
entries=all_entries,
total_duration=total_duration or (all_entries[-1]["end"] if all_entries else 0),
)
def _distribute_evenly(
self,
lines: list[str],
section: str,
start: float,
end: float,
) -> list[dict]:
"""均匀分配歌词行"""
if not lines:
return []
if len(lines) == 1:
return [{"start": round(start, 3), "end": round(end, 3),
"text": lines[0].strip(), "section": section}]
duration = end - start
line_duration = duration / len(lines)
entries = []
for i, line in enumerate(lines):
line_start = start + i * line_duration
line_end = line_start + line_duration
entries.append({
"start": round(line_start, 3),
"end": round(line_end, 3),
"text": line.strip(),
"section": section,
})
return entries
def _fallback_linear(
self, parsed: list[dict], total_duration: float
) -> LyricTimeline:
"""无节拍时的回退方案"""
if not total_duration:
return LyricTimeline()
all_lines = []
for ps in parsed:
for line in ps["lines"]:
all_lines.append({"text": line.strip(), "section": ps["label"]})
if not all_lines:
return LyricTimeline()
per_line = total_duration / len(all_lines)
entries = [
{
"start": round(i * per_line, 3),
"end": round((i + 1) * per_line, 3),
"text": e["text"],
"section": e["section"],
}
for i, e in enumerate(all_lines)
]
return LyricTimeline(
entries=entries,
total_duration=total_duration,
)
@@ -0,0 +1,392 @@
"""
layer4_alignment/stem_aligner.py — 分轨时间对齐模块
功能:将各分轨时间伸缩到目标 BPM / 目标时长
核心场景:卡点改编时原曲 BPM -> 目标 BPM 的时间映射
支持方法:
1. librosa.effects.time_stretch (相位声码器)
2. 节拍网格对齐 (beat grid snapping)
3. 交叉淡入淡出连接 (拼接时)
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Literal
import numpy as np
@dataclass
class AlignResult:
"""对齐结果"""
aligned_paths: dict[str, str] = field(default_factory=dict) # {stem: path}
original_duration: float = 0.0
target_duration: float = 0.0
stretch_ratios: dict[str, float] = field(default_factory=dict) # {stem: ratio}
method_used: str = ""
@property
def is_aligned(self) -> bool:
return len(self.aligned_paths) > 0
class StemAligner:
"""
分轨时间对齐器
将分轨从原始节奏伸缩到目标节奏。
支持独立对齐每轨或统一对齐。
"""
STRETCH_METHODS: list[str] = ["phase_vocoder", "librosa", "wsola"]
def __init__(
self,
method: str = "phase_vocoder",
crossfade: float = 0.05,
time_tolerance: float = 0.1,
):
if method not in self.STRETCH_METHODS:
raise ValueError(f"不支持的时间伸缩方法 '{method}', 可选: {self.STRETCH_METHODS}")
self.method = method
self.crossfade = crossfade
self.time_tolerance = time_tolerance
def align_to_bpm(
self,
stems: dict[str, str],
source_bpm: float,
target_bpm: float,
output_dir: str | Path = "./output/aligned/",
align_separately: bool = False,
) -> AlignResult:
"""
按 BPM 比率对齐分轨
参数:
stems: {stem_name: audio_path}
source_bpm: 原曲 BPM
target_bpm: 目标 BPM
output_dir: 输出目录
align_separately: True=每轨独立比例, False=统一比例
返回:
AlignResult
"""
if target_bpm <= 0 or source_bpm <= 0:
raise ValueError("BPM 必须 > 0")
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
ratio = target_bpm / source_bpm
print(f" [align] BPM 对齐: {source_bpm} → {target_bpm} (比率: {ratio:.4f})")
return self._align(
stems, ratio, output_dir, align_separately
)
def align_to_duration(
self,
stems: dict[str, str],
source_duration: float,
target_duration: float,
output_dir: str | Path = "./output/aligned/",
align_separately: bool = False,
) -> AlignResult:
"""
按时长比例对齐分轨
参数:
stems: {stem_name: audio_path}
source_duration: 原曲时长 (秒)
target_duration: 目标时长 (秒)
output_dir: 输出目录
align_separately: True=每轨独立
返回:
AlignResult
"""
if target_duration <= 0 or source_duration <= 0:
raise ValueError("时长必须 > 0")
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
ratio = target_duration / source_duration
print(f" [align] 时长对齐: {source_duration:.1f}s → {target_duration:.1f}s"
f" (比率: {ratio:.4f})")
return self._align(
stems, ratio, output_dir, align_separately
)
def align_with_beats(
self,
stems: dict[str, str],
source_beats: list[float],
target_beats: list[float],
output_dir: str | Path = "./output/aligned/",
) -> AlignResult:
"""
按节拍网格对齐 (精确卡点)
将源节拍映射到目标节拍位置,逐段伸缩。
参数:
stems: {stem_name: audio_path}
source_beats: 原节奏拍列表 (秒)
target_beats: 目标节拍列表 (秒)
output_dir: 输出目录
返回:
AlignResult
"""
if not source_beats or not target_beats:
raise ValueError("节拍列表不能为空")
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
print(f" [align] 节拍网格对齐: {len(source_beats)} → {len(target_beats)} 拍")
aligned = {}
stretch_ratios = {}
for stem_name, audio_path in stems.items():
if not Path(audio_path).exists():
print(f" [align] ⚠ 分轨不存在: {audio_path}, 跳过")
continue
out_path = output_dir / f"aligned_{stem_name}.wav"
result_path = self._beat_grid_stretch(
str(audio_path), source_beats, target_beats, str(out_path)
)
if result_path:
aligned[stem_name] = result_path
stretch_ratios[stem_name] = len(target_beats) / len(source_beats)
return AlignResult(
aligned_paths=aligned,
original_duration=source_beats[-1] if source_beats else 0,
target_duration=target_beats[-1] if target_beats else 0,
stretch_ratios=stretch_ratios,
method_used="beat_grid",
)
# ---- 内部 ----
def _align(
self,
stems: dict[str, str],
ratio: float,
output_dir: Path,
separate: bool,
) -> AlignResult:
"""通用对齐"""
aligned = {}
stretch_ratios = {}
# — 加载第一轨获取原时长 —
first_path = next(iter(stems.values()))
y_ref, sr_ref = self._load_audio(first_path)
orig_duration = len(y_ref) / sr_ref
for stem_name, audio_path in stems.items():
if not Path(audio_path).exists():
print(f" [align] ⚠ 分轨不存在: {audio_path}, 跳过")
continue
out_path = output_dir / f"aligned_{stem_name}.wav"
if separate:
# 独立对齐:每轨先测实际时长再计算比例
y, sr = self._load_audio(audio_path)
actual_duration = len(y) / sr
target_dur = actual_duration * ratio
actual_ratio = target_dur / actual_duration
else:
actual_ratio = ratio
result_path = self._time_stretch(
str(audio_path), actual_ratio, str(out_path)
)
if result_path:
aligned[stem_name] = result_path
stretch_ratios[stem_name] = actual_ratio
return AlignResult(
aligned_paths=aligned,
original_duration=orig_duration,
target_duration=orig_duration * ratio,
stretch_ratios=stretch_ratios,
method_used=self.method,
)
def _time_stretch(self, input_path: str, ratio: float, output_path: str) -> str | None:
"""
时间伸缩
参数:
input_path: 输入音频路径
ratio: 伸缩比 (>1 变快/缩短, <1 变慢/拉长)
output_path: 输出路径
返回:
输出路径,失败返回 None
"""
if abs(ratio - 1.0) < 0.001:
# 无需伸缩: 直接复制
import shutil
shutil.copy2(input_path, output_path)
return output_path
print(f" [align] 伸缩: ratio={ratio:.4f}")
try:
y, sr = self._load_audio(input_path)
if self.method == "librosa":
import librosa
y_stretched = librosa.effects.time_stretch(y, rate=ratio)
elif self.method == "phase_vocoder":
# 用相位声码器
y_stretched = self._phase_vocoder_stretch(y, ratio)
else: # wsola
y_stretched = self._wsola_stretch(y, ratio)
self._save_audio(output_path, y_stretched, sr)
return output_path
except Exception as e:
print(f" [align] ⚠ 伸缩失败: {e}")
# 回退: 复制原文件
import shutil
shutil.copy2(input_path, output_path)
return output_path
def _beat_grid_stretch(
self,
audio_path: str,
source_beats: list[float],
target_beats: list[float],
output_path: str,
) -> str | None:
"""
逐段节拍网格伸缩
将源节拍分段,每段分别伸缩到目标节拍间隔。
"""
try:
y, sr = self._load_audio(audio_path)
# 限制处理范围
max_beats = min(len(source_beats), len(target_beats))
if max_beats < 2:
import shutil
shutil.copy2(audio_path, output_path)
return output_path
segments = []
for i in range(max_beats - 1):
s_start = int(source_beats[i] * sr)
s_end = int(source_beats[i + 1] * sr)
t_len = target_beats[i + 1] - target_beats[i]
s_len = source_beats[i + 1] - source_beats[i]
if s_len <= 0 or t_len <= 0:
continue
segment = y[..., s_start:s_end] if y.ndim == 1 else y[:, s_start:s_end]
if segment.size == 0:
continue
ratio = s_len / t_len # 注意: librosa 的 stretch 反向
stretched = librosa.effects.time_stretch(
np.asfortranarray(segment), rate=1/ratio
)
segments.append(stretched)
if not segments:
import shutil
shutil.copy2(audio_path, output_path)
return output_path
# 拼接 (带交叉淡入淡出)
result = self._crossfade_join(segments, sr)
self._save_audio(output_path, result, sr)
return output_path
except Exception as e:
print(f" [align] ⚠ 节拍网格对齐失败: {e}")
import shutil
shutil.copy2(audio_path, output_path)
return output_path
def _phase_vocoder_stretch(self, y: np.ndarray, ratio: float) -> np.ndarray:
"""相位声码器实现的时间伸缩"""
from librosa.core import phase_vocoder
from librosa import stft, istft
D = stft(y)
D_stretched = phase_vocoder(D, rate=ratio)
y_stretched = istft(D_stretched)
return y_stretched
def _wsola_stretch(self, y: np.ndarray, ratio: float) -> np.ndarray:
"""WSOLA 时间伸缩 (librosa 回退)"""
import librosa
return librosa.effects.time_stretch(y, rate=ratio)
def _crossfade_join(
self, segments: list[np.ndarray], sr: int
) -> np.ndarray:
"""带交叉淡入淡出的音频拼接"""
if len(segments) == 1:
return segments[0]
fade_len = int(self.crossfade * sr)
# 确保所有段 mono (1D)
segments_1d = []
for seg in segments:
if seg.ndim > 1:
seg = np.mean(seg, axis=0)
segments_1d.append(seg)
result = segments_1d[0]
for seg in segments_1d[1:]:
if len(result) < fade_len or len(seg) < fade_len:
result = np.concatenate([result, seg])
continue
fade_out = np.linspace(1, 0, fade_len)
fade_in = np.linspace(0, 1, fade_len)
cross_start = len(result) - fade_len
result[cross_start:] *= fade_out
seg[:fade_len] *= fade_in
result = np.concatenate([
result[:cross_start],
result[cross_start:] + seg[:fade_len],
seg[fade_len:],
])
return result
@staticmethod
def _load_audio(path: str) -> tuple[np.ndarray, int]:
"""加载音频"""
import librosa
return librosa.load(path, sr=None, mono=True)
@staticmethod
def _save_audio(path: str, y: np.ndarray, sr: int):
"""保存音频"""
import soundfile as sf
sf.write(path, y, sr)
@@ -0,0 +1,8 @@
"""AudioLine 输出层 — 导出 / 歌词格式化"""
from .exporter import Exporter, ExportResult
from .lyric_formatter import LyricFormatter, FormattedLyrics
__all__ = [
"Exporter", "ExportResult",
"LyricFormatter", "FormattedLyrics",
]
@@ -0,0 +1,282 @@
"""
layer5_output/exporter.py — 多格式输出导出模块
功能:将混音结果导出为多种格式
支持:WAV (PCM) / MP3 / FLAC / AAC
可选:整轨导出 / 分轨导出 / 带歌词时间轴导出
"""
from __future__ import annotations
import json
import os
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
@dataclass
class ExportResult:
"""导出结果"""
exported_files: list[dict] = field(default_factory=list) # [{format, path, size_kb}]
formats_completed: list[str] = field(default_factory=list)
total_size_kb: float = 0.0
duration: float = 0.0
has_lyrics: bool = False
@property
def summary(self) -> str:
return f"{len(self.exported_files)} 文件, {self.total_size_kb:.0f} KB, " \
f"{self.duration:.1f}s, 格式: {', '.join(self.formats_completed)}"
class Exporter:
"""
多格式导出器
支持格式:
- WAV: 无损 PCM 16/24-bit
- MP3: CBR/VBR, 支持 128k/192k/320k
- FLAC: 无损压缩
- AAC: 高效有损 (M4A)
"""
SUPPORTED_FORMATS = {"wav", "mp3", "flac", "aac"}
def __init__(
self,
config: dict | None = None,
):
self.config = config or {}
export_cfg = config.get("export", {}) if config else {}
self.default_formats = export_cfg.get("formats", ["wav", "mp3"])
self.mp3_bitrate = export_cfg.get("mp3_bitrate", "320k")
self.include_stems = export_cfg.get("include_stems", True)
self.stem_labels = export_cfg.get("stem_labels",
["vocals", "drums", "bass", "other", "full_mix"])
self.sample_rate = int(config.get("mixing", {}).get("sample_rate", 44100)) if config else 44100
self.bit_depth = int(config.get("mixing", {}).get("bit_depth", 16)) if config else 16
def export_master(
self,
master_path: str | Path,
output_dir: str | Path = "./output/export/",
formats: list[str] | None = None,
metadata: dict | None = None,
) -> ExportResult:
"""
导出主混音
参数:
master_path: 主混音 WAV 路径
output_dir: 输出目录
formats: 目标格式列表 (默认 config 中的设置)
metadata: 元数据 {title, artist, album, ...}
返回:
ExportResult
"""
master_path = Path(master_path)
if not master_path.exists():
raise FileNotFoundError(f"主混音文件不存在: {master_path}")
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
formats = formats or self.default_formats
stem_name = master_path.stem
print(f" [export] 导出主轨: {master_path.name}")
print(f" [export] 目标格式: {formats}")
exported = []
for fmt in formats:
fmt = fmt.lower().lstrip(".")
if fmt not in self.SUPPORTED_FORMATS:
print(f" [export] ⚠ 不支持格式 '{fmt}', 跳过")
continue
out_path = output_dir / f"{stem_name}.{fmt}"
success = self._convert(master_path, out_path, fmt, metadata)
if success:
size_kb = out_path.stat().st_size / 1024
exported.append({
"format": fmt,
"path": str(out_path.resolve()),
"size_kb": round(size_kb, 1),
})
print(f" [export] {fmt}: {out_path.name} ({size_kb:.0f} KB)")
if not exported:
raise RuntimeError("所有格式导出均失败")
# 获取时长
duration = self._get_duration(master_path)
return ExportResult(
exported_files=exported,
formats_completed=[e["format"] for e in exported],
total_size_kb=sum(e["size_kb"] for e in exported),
duration=duration,
)
def export_stems(
self,
stems: dict[str, str],
output_dir: str | Path = "./output/export/",
formats: list[str] | None = None,
) -> list[ExportResult]:
"""
导出所有分轨
参数:
stems: {stem_name: audio_path, ...}
output_dir: 输出目录
formats: 目标格式
返回:
每轨的 ExportResult 列表
"""
results = []
for name, path in stems.items():
result = self.export_master(
Path(path), output_dir / name, formats
)
results.append(result)
return results
def export_with_lyrics(
self,
master_path: str | Path,
lyric_timeline: Any, # LyricTimeline
output_dir: str | Path = "./output/export/",
formats: list[str] | None = None,
) -> ExportResult:
"""
导出带歌词的主混音
同时导出音频 + LRC/SRT 歌词文件
"""
output_dir = Path(output_dir)
result = self.export_master(master_path, output_dir, formats)
# 导出 LRC
lrc_path = output_dir / f"{Path(master_path).stem}.lrc"
lrc_content = lyric_timeline.to_lrc()
lrc_path.write_text(lrc_content, encoding="utf-8")
result.exported_files.append({
"format": "lrc",
"path": str(lrc_path.resolve()),
"size_kb": round(lrc_path.stat().st_size / 1024, 1),
})
# 导出 SRT
srt_path = output_dir / f"{Path(master_path).stem}.srt"
srt_content = lyric_timeline.to_srt()
srt_path.write_text(srt_content, encoding="utf-8")
result.exported_files.append({
"format": "srt",
"path": str(srt_path.resolve()),
"size_kb": round(srt_path.stat().st_size / 1024, 1),
})
result.has_lyrics = True
result.formats_completed.extend(["lrc", "srt"])
result.total_size_kb = sum(e["size_kb"] for e in result.exported_files)
print(f" [export] 歌词: LRC + SRT 已导出")
return result
# ---- 内部 ----
def _convert(
self,
src: Path,
dst: Path,
fmt: str,
metadata: dict | None = None,
) -> bool:
"""格式转换"""
try:
if fmt == "wav":
return self._to_wav(src, dst)
elif fmt == "mp3":
return self._to_mp3(src, dst, metadata)
elif fmt == "flac":
return self._to_flac(src, dst)
elif fmt == "aac":
return self._to_aac(src, dst, metadata)
return False
except Exception as e:
print(f" [export] {fmt} 转换失败: {e}")
return False
def _to_wav(self, src: Path, dst: Path) -> bool:
"""复制/重编码为 WAV (PCM 16-bit)"""
import soundfile as sf
y, sr = sf.read(str(src))
sf.write(str(dst), y, sr, subtype="PCM_16")
return dst.exists()
def _to_mp3(self, src: Path, dst: Path, metadata: dict | None = None) -> bool:
"""转 MP3 (通过 ffmpeg)"""
cmd = [
"ffmpeg", "-y", "-i", str(src),
"-codec:a", "libmp3lame",
"-b:a", self.mp3_bitrate,
"-ar", str(self.sample_rate),
]
if metadata:
if "title" in metadata:
cmd += ["-metadata", f"title={metadata['title']}"]
if "artist" in metadata:
cmd += ["-metadata", f"artist={metadata['artist']}"]
cmd.append(str(dst))
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
return result.returncode == 0 and dst.exists()
def _to_flac(self, src: Path, dst: Path) -> bool:
"""转 FLAC (通过 ffmpeg)"""
cmd = [
"ffmpeg", "-y", "-i", str(src),
"-codec:a", "flac",
"-ar", str(self.sample_rate),
str(dst),
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
return result.returncode == 0 and dst.exists()
def _to_aac(self, src: Path, dst: Path, metadata: dict | None = None) -> bool:
"""转 AAC/M4A (通过 ffmpeg)"""
cmd = [
"ffmpeg", "-y", "-i", str(src),
"-codec:a", "aac",
"-b:a", "256k",
"-ar", str(self.sample_rate),
]
if metadata:
if "title" in metadata:
cmd += ["-metadata", f"title={metadata['title']}"]
if "artist" in metadata:
cmd += ["-metadata", f"artist={metadata['artist']}"]
cmd.append(str(dst))
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
return result.returncode == 0 and dst.exists()
@staticmethod
def _get_duration(path: Path) -> float:
"""获取音频时长(用 lib.ffmpeg —— 本机 ffprobe 实为 ffmpeg 符号链接)"""
try:
import sys as _s
from pathlib import Path as _P
_scripts = _P(__file__).resolve().parents[3]
if str(_scripts) not in _s.path:
_s.path.insert(0, str(_scripts))
from lib.ffmpeg import probe_duration
return round(probe_duration(str(path)), 2)
except Exception:
return 0.0
@@ -0,0 +1,226 @@
"""
layer5_output/lyric_formatter.py — 歌词格式化模块
功能:将歌词时间轴输出为多种字幕/歌词格式
支持格式:
- LRC (LyRiCs) — 标准歌词格式
- SRT (SubRip) — 通用字幕格式
- ASS (Advanced SubStation Alpha) — 高级字幕 (带样式)
- TTML (Timed Text Markup Language) — WebVTT 替代
- plain text — 纯文本 (无时间戳)
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
@dataclass
class FormattedLyrics:
"""格式化歌词结果"""
lrc: str = ""
srt: str = ""
ass: str = ""
ttml: str = ""
plain: str = ""
def save_all(self, output_dir: str | Path, basename: str = "lyrics") -> dict[str, str]:
"""保存所有格式到文件"""
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
saved = {}
fmt_map = {
"lrc": self.lrc,
"srt": self.srt,
"ass": self.ass,
"ttml": self.ttml,
"txt": self.plain,
}
for ext, content in fmt_map.items():
if content:
path = output_dir / f"{basename}.{ext}"
path.write_text(content, encoding="utf-8")
saved[ext] = str(path.resolve())
return saved
class LyricFormatter:
"""
歌词格式化器
将时间轴条目(entries)转换为各种字幕格式。
支持自定义 ASS 样式。
"""
# ASS 默认样式
DEFAULT_ASS_STYLE = (
"[V4+ Styles]\n"
"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, "
"OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, "
"ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, "
"Alignment, MarginL, MarginR, MarginV, Encoding\n"
"Style: Default, Arial, 24, &H00FFFFFF, &H000000FF, "
"&H00000000, &H00000000, 0, 0, 0, 0, "
"100, 100, 0, 0, 1, 1, 1, 2, 20, 20, 40, 1\n"
)
def format_all(
self,
entries: list[dict],
total_duration: float = 0.0,
title: str = "AudioLine",
ass_style: str | None = None,
) -> FormattedLyrics:
"""
生成所有格式的歌词
参数:
entries: [{start, end, text, section}] 时间轴条目
total_duration: 总时长
title: 标题
ass_style: ASS 样式定义 (可选)
返回:
FormattedLyrics
"""
return FormattedLyrics(
lrc=self.to_lrc(entries, title),
srt=self.to_srt(entries),
ass=self.to_ass(entries, ass_style or self.DEFAULT_ASS_STYLE),
ttml=self.to_ttml(entries, total_duration, title),
plain=self.to_plain(entries),
)
# ---- LRC ----
def to_lrc(self, entries: list[dict], title: str = "AudioLine") -> str:
"""转为 LRC 格式"""
lines = [
"[ti:{}]".format(title),
"[offset:0]",
]
last_section = ""
for entry in entries:
section = entry.get("section", "")
if section and section != last_section:
lines.append("")
lines.append("[{: >8}]".format(section))
last_section = section
start = entry["start"]
mm, ss = divmod(start, 60)
cs = int((start - int(start)) * 100)
lines.append("[{:02d}:{:02d}.{:02d}]{:s}".format(
int(mm), int(ss), cs, entry["text"]
))
return "\n".join(lines) + "\n"
# ---- SRT ----
def to_srt(self, entries: list[dict]) -> str:
"""转为 SRT 格式"""
blocks = []
for i, entry in enumerate(entries, 1):
start_srt = self._sec_to_srt(entry["start"])
end_srt = self._sec_to_srt(entry["end"])
section = entry.get("section", "")
text = entry["text"]
if section:
text = f"{text}\n({section})"
blocks.append("{:d}\n{:s} --> {:s}\n{:s}\n".format(
i, start_srt, end_srt, text
))
return "\n".join(blocks)
# ---- ASS ----
def to_ass(self, entries: list[dict], style_block: str = "") -> str:
"""转为 ASS 格式"""
style_block = style_block or self.DEFAULT_ASS_STYLE
header = (
"[Script Info]\n"
"ScriptType: v4.00+\n"
"Title: AudioLine Lyrics\n"
"WrapStyle: 2\n"
"ScaledBorderAndShadow: yes\n"
)
body = ""
for entry in entries:
start_ass = self._sec_to_ass(entry["start"])
end_ass = self._sec_to_ass(entry["end"])
text = entry["text"].replace("\n", "\\N")
section = entry.get("section", "")
if section:
text = f"{{\\i1}}{section}{{\\i0}} - {text}"
body += "Dialogue: 0,{start},{end},Default,,0,0,0,,{text}\n".format(
start=start_ass, end=end_ass, text=text
)
return header + style_block + "\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n" + body
# ---- TTML ----
def to_ttml(self, entries: list[dict], total_duration: float, title: str = "") -> str:
"""转为 TTML (类似 WebVTT) 格式"""
lines = ["WEBVTT", f"Title: {title}"]
if total_duration:
lines.append(f"Duration: {self._sec_to_srt(total_duration)}")
lines.append("")
for entry in entries:
start_vtt = self._sec_to_srt(entry["start"]).replace(",", ".")
end_vtt = self._sec_to_srt(entry["end"]).replace(",", ".")
lines.append("{:s} --> {:s}".format(start_vtt, end_vtt))
lines.append(entry["text"])
lines.append("")
return "\n".join(lines)
# ---- Plain ----
def to_plain(self, entries: list[dict]) -> str:
"""转为纯文本"""
last_section = ""
lines = []
for entry in entries:
section = entry.get("section", "")
if section and section != last_section:
lines.append("")
lines.append("[{}]".format(section))
last_section = section
lines.append(entry["text"])
return "\n".join(lines).strip()
# ---- 辅助 ----
@staticmethod
def _sec_to_srt(sec: float) -> str:
"""秒 → SRT 时间格式 (HH:MM:SS,mmm)"""
h, rem = divmod(sec, 3600)
m, s = divmod(rem, 60)
ms = int((s - int(s)) * 1000)
return "{:02d}:{:02d}:{:02d},{:03d}".format(int(h), int(m), int(s), ms)
@staticmethod
def _sec_to_ass(sec: float) -> str:
"""秒 → ASS 时间格式 (H:MM:SS.cc)"""
h, rem = divmod(sec, 3600)
m, s = divmod(rem, 60)
cs = int((s - int(s)) * 100)
return "{:d}:{:02d}:{:02d}.{:02d}".format(int(h), int(m), int(s), cs)
@@ -0,0 +1,434 @@
"""
orchestrator.py — 音频创作管线编排控制器
职责:串联所有 6 层模块,按顺序执行 AudioTask。
支持模块选择、dry-run、状态报告、错误恢复。
"""
from __future__ import annotations
import json
import os
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable
import yaml
# Layer 1 — 输入
from layer1_input.collector import AudioTask, AudioSource, AudioCollector
# Layer 2 — 分析
from layer2_analysis.stem_separator import StemSeparator
from layer2_analysis.beat_detector import BeatDetector, BeatResult
from layer2_analysis.structure_parser import StructureParser, StructureResult
from layer2_analysis.emotion_analyzer import EmotionAnalyzer, EmotionResult
# Layer 3 — 创作
from layer3_creation.module_a_rhythm import RhythmModule, RhythmResult
from layer3_creation.module_b_lyrics import LyricsModule, LyricsResult
from layer3_creation.module_c_melody import MelodyModule, MelodyResult
from layer3_creation.module_d_mix import MixModule, MixResult
# Layer 4 — 对齐
from layer4_alignment.lyric_aligner import LyricAligner, LyricTimeline
from layer4_alignment.stem_aligner import StemAligner, AlignResult
# Layer 5 — 输出
from layer5_output.exporter import Exporter, ExportResult
from layer5_output.lyric_formatter import LyricFormatter, FormattedLyrics
# ---------------------------------------------------------------------------
# 结果数据类
# ---------------------------------------------------------------------------
@dataclass
class AudioLineResult:
"""完整管线输出"""
task: AudioTask = field(default_factory=lambda: AudioTask(
source=AudioSource(path="", source_type="command")
))
# Layer 2 结果
stems: dict[str, str] = field(default_factory=dict)
beat: BeatResult | None = None
structure: StructureResult | None = None
emotion: EmotionResult | None = None
# Layer 3 结果
rhythm: RhythmResult | None = None
lyrics: LyricsResult | None = None
melody: MelodyResult | None = None
mix: MixResult | None = None
# Layer 4 结果
lyric_timeline: LyricTimeline | None = None
stem_alignment: AlignResult | None = None
# Layer 5 结果
export: ExportResult | None = None
formatted_lyrics: FormattedLyrics | None = None
# 状态
modules_run: list[str] = field(default_factory=list)
errors: list[dict] = field(default_factory=list) # [{module, error}]
timing: dict[str, float] = field(default_factory=dict) # {module: elapsed_sec}
success: bool = False
@property
def summary(self) -> str:
"""生成执行摘要"""
lines = [
f"任务: {self.task.operation} | 源: {self.task.source.original_filename or '(指令)'}",
f"模块: {', '.join(self.modules_run) or '(无)'}",
]
if self.beat:
lines.append(f"BPM: {self.beat.bpm:.1f} | 情绪: {self.emotion.mood if self.emotion else 'N/A'}")
if self.mix:
lines.append(f"混音: {self.mix.master_path}")
if self.export:
lines.append(f"导出: {self.export.summary}")
if self.errors:
lines.append(f"错误: {len(self.errors)} 个")
for e in self.errors[:3]:
lines.append(f" ⚠ {e['module']}: {e['error']}")
lines.append(f"成功: {'✅' if self.success else '❌'}")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# 编排器
# ---------------------------------------------------------------------------
class AudioLineOrchestrator:
"""
音频创作管线编排器
按 AudioTask.modules 配置依次执行各层。
跳过失败的模块(不阻塞整条管线)。
"""
def __init__(self, config_path: str | Path | None = None):
self.config = self._load_config(config_path)
self.work_dir = Path(self.config.get("paths", {}).get("work_dir", "./work/audio_line"))
self.output_dir = Path(self.config.get("paths", {}).get("output_dir", "./output/audio_line"))
self.work_dir.mkdir(parents=True, exist_ok=True)
self.output_dir.mkdir(parents=True, exist_ok=True)
# — 初始化各层模块 —
self.collector = AudioCollector(str(self.work_dir))
sep_cfg = self.config.get("separation", {})
self.stem_sep = StemSeparator(
model=sep_cfg.get("model", "htdemucs_ft"),
device=sep_cfg.get("device", "cpu"),
shifts=sep_cfg.get("shifts", 2),
overlap=sep_cfg.get("overlap", 0.25),
out_dir=str(self.work_dir / "separated"),
)
beat_cfg = self.config.get("beat", {})
self.beat_det = BeatDetector(
min_bpm=beat_cfg.get("min_bpm", 60),
max_bpm=beat_cfg.get("max_bpm", 200),
)
self.structure_parser = StructureParser()
self.emotion_analyzer = EmotionAnalyzer()
self.rhythm_module = RhythmModule(self.config)
self.lyrics_module = LyricsModule(self.config)
self.melody_module = MelodyModule(self.config)
self.mix_module = MixModule(self.config)
align_cfg = self.config.get("alignment", {})
self.lyric_aligner = LyricAligner(
tolerance=align_cfg.get("time_tolerance", 0.1),
)
self.stem_aligner = StemAligner(
method=align_cfg.get("stretch_method", "phase_vocoder"),
crossfade=align_cfg.get("crossfade", 0.05),
)
self.exporter = Exporter(self.config)
self.lyric_formatter = LyricFormatter()
def run(self, task: AudioTask) -> AudioLineResult:
"""
执行管线
参数:
task: AudioTask — 由 AudioCollector 创建的标准化任务
返回:
AudioLineResult
"""
result = AudioLineResult(task=task)
print(f"\n{'='*60}")
print(f"AudioLine 管线启动")
print(f" operation: {task.operation}")
print(f" source: {task.source.path or '(指令模式)'}")
print(f" modules: {', '.join(sorted(task.modules))}")
print(f"{'='*60}\n")
modules_to_run = self._plan_modules(task)
for module_name, callable_fn, ctx_key in modules_to_run:
if module_name not in task.modules:
continue
start = time.time()
print(f"\n--- [{module_name}] ---")
try:
callable_fn(result)
elapsed = time.time() - start
result.modules_run.append(module_name)
result.timing[module_name] = round(elapsed, 2)
print(f" [{module_name}] ✅ 完成 ({elapsed:.1f}s)")
except Exception as e:
elapsed = time.time() - start
result.errors.append({"module": module_name, "error": str(e)})
result.timing[module_name] = round(elapsed, 2)
print(f" [{module_name}] ❌ 失败: {e}")
print(f" [{module_name}] 跳过,继续下一步")
result.success = len(result.errors) == 0
print(f"\n{'='*60}")
print(f"管线结束 | 模块: {len(result.modules_run)}/{len(modules_to_run)}"
f" | 错误: {len(result.errors)} | 耗时: {sum(result.timing.values()):.1f}s")
if result.success:
print(f"状态: ✅ 成功")
else:
print(f"状态: ⚠ 部分成功 ({len(result.errors)} 个错误)")
print(f"{'='*60}\n")
return result
# ---- 模块路由 ----
def _plan_modules(self, task: AudioTask) -> list[tuple[str, Callable, str]]:
"""
规划模块执行顺序
返回: [(module_name, callable_fn, context_key)]
"""
return [
# Layer 2 — 分析
("separation", self._run_stem_separation, "stems"),
("analysis", self._run_analysis, "analysis"),
# Layer 3 — 创作
("rhythm", self._run_rhythm, "rhythm"),
("lyrics", self._run_lyrics, "lyrics"),
("melody", self._run_melody, "melody"),
("mix", self._run_mix, "mix"),
# Layer 4 — 对齐
("alignment", self._run_alignment, "alignment"),
# Layer 5 — 输出
("export", self._run_export, "export"),
]
# ---- Layer 2 ----
def _run_stem_separation(self, result: AudioLineResult):
"""音轨分离"""
task = result.task
if not task.source.path:
print(" [orchestrator] ⚠ 无音频源,跳过音轨分离")
return
result.stems = self.stem_sep.separate(task.source.path)
def _run_analysis(self, result: AudioLineResult):
"""执行所有分析"""
# 用原始音频或 vocals stem 分析
audio_for_analysis = result.stems.get("vocals", "")
if not audio_for_analysis or not Path(audio_for_analysis).exists():
audio_for_analysis = result.task.source.path
if not audio_for_analysis:
print(" [orchestrator] ⚠ 无音频可用于分析")
return
# — BPM/节拍 —
result.beat = self.beat_det.detect(audio_for_analysis)
# — 结构解析 —
result.structure = self.structure_parser.parse(
audio_for_analysis, result.beat.beats
)
# — 情绪分析 —
result.emotion = self.emotion_analyzer.analyze(
audio_for_analysis, result.beat.bpm
)
# ---- Layer 3 ----
def _run_rhythm(self, result: AudioLineResult):
"""节奏重塑"""
task = result.task
bpm = result.beat.bpm if result.beat else 120.0
target_bpm = task.target_bpm or bpm
output_dir = self.output_dir / "rhythm"
output_dir.mkdir(parents=True, exist_ok=True)
result.rhythm = self.rhythm_module.generate(
bpm=target_bpm,
genre=task.style,
duration=task.duration or 30,
output_path=str(output_dir / "drum_track.wav"),
)
def _run_lyrics(self, result: AudioLineResult):
"""歌词创作"""
# 如果有原始歌词,从原曲分析获取
original_lyrics = "" # 默认空,需要时用户提供
output_dir = self.output_dir / "lyrics"
output_dir.mkdir(parents=True, exist_ok=True)
# 仅当有主题时执行改写
if result.task.topic:
result.lyrics = self.lyrics_module.rewrite(
original_lyrics=original_lyrics,
topic=result.task.topic,
style=result.task.style,
output_path=str(output_dir / "rewritten_lyrics.txt"),
)
else:
print(" [orchestrator] ⚠ 无歌词主题,跳过歌词改写")
def _run_melody(self, result: AudioLineResult):
"""旋律编曲"""
audio_path = result.stems.get("vocals", "") or result.task.source.path
if not audio_path:
print(" [orchestrator] ⚠ 无音频源,跳过旋律编曲")
return
output_dir = self.output_dir / "melody"
output_dir.mkdir(parents=True, exist_ok=True)
result.melody = self.melody_module.remix(
audio_path=audio_path,
target_style=result.task.style,
duration=result.task.duration or 0,
output_path=str(output_dir / "remix.wav"),
)
def _run_mix(self, result: AudioLineResult):
"""混音母带"""
if not result.stems:
print(" [orchestrator] ⚠ 无分轨可供混音,跳过")
return
output_dir = self.output_dir / "mix"
output_dir.mkdir(parents=True, exist_ok=True)
# 如果有生成的鼓点轨道,加入混音
stems_to_mix = dict(result.stems)
if result.rhythm and result.rhythm.drum_track_path:
stems_to_mix["drum_track"] = result.rhythm.drum_track_path
result.mix = self.mix_module.mix(
stems=stems_to_mix,
output_path=str(output_dir / "master.wav"),
include_stems=True,
)
# ---- Layer 4 ----
def _run_alignment(self, result: AudioLineResult):
"""对齐处理"""
# — 歌词对齐 —
if result.lyrics and result.beat:
lyrics_text = result.lyrics.rewritten_lyrics or result.lyrics.original_lyrics
sections = result.structure.segments if result.structure else None
result.lyric_timeline = self.lyric_aligner.align(
lyrics=lyrics_text,
beats=result.beat.beats,
sections=sections,
total_duration=result.beat.duration_sec,
)
# — 分轨对齐 —
if result.stems and result.beat:
# 如果有目标 BPM,进行 BPM 对齐
task = result.task
target_bpm = task.target_bpm
if target_bpm and target_bpm != result.beat.bpm:
output_dir = self.output_dir / "aligned"
output_dir.mkdir(parents=True, exist_ok=True)
result.stem_alignment = self.stem_aligner.align_to_bpm(
stems=result.stems,
source_bpm=result.beat.bpm,
target_bpm=target_bpm,
output_dir=str(output_dir),
)
# ---- Layer 5 ----
def _run_export(self, result: AudioLineResult):
"""导出"""
output_dir = self.output_dir / "export"
output_dir.mkdir(parents=True, exist_ok=True)
# — 导出混音 —
master_path = result.mix.master_path if result.mix else ""
if master_path and Path(master_path).exists():
result.export = self.exporter.export_master(
master_path=master_path,
output_dir=str(output_dir),
)
# — 导出带歌词版本 —
if result.lyric_timeline:
result.export = self.exporter.export_with_lyrics(
master_path=master_path,
lyric_timeline=result.lyric_timeline,
output_dir=str(output_dir),
)
# — 歌词格式化 —
if result.lyric_timeline and result.lyric_timeline.entries:
result.formatted_lyrics = self.lyric_formatter.format_all(
entries=result.lyric_timeline.entries,
total_duration=result.lyric_timeline.total_duration,
title=f"AudioLine_{result.task.operation}",
)
result.formatted_lyrics.save_all(
str(output_dir / "lyrics"),
basename="lyrics",
)
# ---- 配置加载 ----
@staticmethod
def _load_config(config_path: str | Path | None) -> dict:
"""加载 YAML 配置"""
if config_path and Path(config_path).exists():
with open(config_path, "r", encoding="utf-8") as f:
return yaml.safe_load(f) or {}
# 尝试默认路径
for path in [
"./config.yaml",
"./config/local.yaml",
]:
p = Path(path)
if p.exists():
with open(p, "r", encoding="utf-8") as f:
return yaml.safe_load(f) or {}
print("[orchestrator] ⚠ 未找到配置文件,使用默认值")
return {}
@@ -0,0 +1,10 @@
"""
AVE 07_bgm_generator — 背景音乐生成 (三阶路由)
用法:
from bgm_generator.suno import generate_bgm, get_available_moods
generate_bgm(mood="calm", duration=30, output="/tmp/bgm.wav")
"""
from .suno import generate_bgm, get_available_moods
__all__ = ["generate_bgm", "get_available_moods"]
@@ -0,0 +1,203 @@
#!/usr/bin/env python3
"""
AVE BGM 音乐库助手 v1.0
下载 Pixabay Music 免费可商用 BGM 到本地库
用法:
python bgm_download.py search --mood calm
python bgm_download.py list
python bgm_download.py scan # 扫描已下载文件更新 library.json
python bgm_download.py import --file /path/to/music.mp3 --mood happy
注意:
Pixabay Music 没有公开 API,本脚本提供两种方式添加音乐:
1. 打开 https://pixabay.com/music/ 手动下载 → 放到对应情绪目录
2. 运行 scan 自动扫描目录更新 library.json
"""
import argparse
import json
import os
import subprocess
import sys
from pathlib import Path
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(SCRIPT_DIR, ".."))
from lib.logger import get_logger
logger = get_logger("bgm_lib")
# ── 常量 ──
LIBRARY_DIR = Path.home() / "workbuddy-agent-os" / "agent-local" / "tools" / "ave" / "cache" / "bgm_library"
LIBRARY_CONFIG = LIBRARY_DIR / "library.json"
MOODS = ["calm", "soothing", "happy", "excited", "sad", "mystery",
"professional", "funny", "inspiring", "normal"]
def main():
setup()
parser = argparse.ArgumentParser(description="BGM 音乐库助手")
sub = parser.add_subparsers(dest="command", required=True)
# list
sub.add_parser("list", help="列出库中所有音乐")
# scan
sub.add_parser("scan", help="扫描文件系统更新 library.json")
# search (open browser)
p_search = sub.add_parser("search", help="打开浏览器搜索指定情绪的 BGM")
p_search.add_argument("--mood", default="calm", choices=MOODS, help="情绪")
# import
p_import = sub.add_parser("import", help="手动导入一个音乐文件")
p_import.add_argument("--file", required=True, help="音乐文件路径")
p_import.add_argument("--mood", required=True, choices=MOODS, help="情绪分类")
p_import.add_argument("--title", default="", help="曲目标题")
p_import.add_argument("--source", default="manual", help="来源")
args = parser.parse_args()
if args.command == "list":
cmd_list()
elif args.command == "scan":
cmd_scan()
elif args.command == "search":
cmd_search(args.mood)
elif args.command == "import":
cmd_import(args.file, args.mood, args.title, args.source)
else:
parser.print_help()
def setup():
"""确保目录结构存在"""
LIBRARY_DIR.mkdir(parents=True, exist_ok=True)
for m in MOODS:
(LIBRARY_DIR / m).mkdir(parents=True, exist_ok=True)
if not LIBRARY_CONFIG.exists():
logger.info("初始化 library.json")
cfg = {"库": {m: {"曲目": [], "说明": ""} for m in MOODS}}
cfg["基础路径"] = str(LIBRARY_DIR)
cfg["版本"] = "v1.0"
with open(LIBRARY_CONFIG, "w", encoding="utf-8") as f:
json.dump(cfg, f, ensure_ascii=False, indent=2)
def cmd_list():
"""列出所有已导入的音乐"""
cfg = load_config()
total = 0
for mood in MOODS:
tracks = cfg.get("库", {}).get(mood, {}).get("曲目", [])
valid = [t for t in tracks if t.get("文件名")]
if valid:
print(f"\n [{mood}] ({len(valid)} 首)")
for t in valid:
fname = t.get("文件名", "?")
title = t.get("标题", fname)
duration = t.get("时长", 0)
print(f" {title:30s} {duration:4.1f}s {fname}")
total += len(valid)
print(f"\n 共 {total} 首 BGM")
def cmd_scan():
"""扫描目录更新 library.json"""
cfg = load_config()
new_count = 0
for mood in MOODS:
mood_dir = LIBRARY_DIR / mood
if not mood_dir.exists():
continue
existing = {t.get("文件名") for t in cfg["库"][mood]["曲目"]}
for f in sorted(mood_dir.iterdir()):
if f.suffix.lower() in (".mp3", ".wav", ".m4a", ".ogg", ".flac"):
if f.name not in existing:
duration = get_duration(str(f))
cfg["库"][mood]["曲目"].append({
"文件名": f.name,
"标题": f.stem,
"时长": duration,
"源": "scan_import",
})
new_count += 1
print(f" ✅ 新增 [{mood}] {f.name} ({duration:.1f}s)")
save_config(cfg)
print(f"\n扫描完成,新增 {new_count} 首")
def cmd_search(mood: str):
"""打开浏览器搜索 Pixabay Music"""
urls = {
"calm": "https://pixabay.com/music/search/calm/",
"soothing": "https://pixabay.com/music/search/relaxing/",
"happy": "https://pixabay.com/music/search/upbeat/",
"excited": "https://pixabay.com/music/search/energetic/",
"sad": "https://pixabay.com/music/search/sad/",
"mystery": "https://pixabay.com/music/search/cinematic/",
"professional": "https://pixabay.com/music/search/corporate/",
"funny": "https://pixabay.com/music/search/playful/",
"inspiring": "https://pixabay.com/music/search/inspiring/",
"normal": "https://pixabay.com/music/search/background/",
}
url = urls.get(mood, urls["normal"])
print(f" 打开浏览器: {url}")
print(f" 操作: 试听 → 免费下载 → 放入 bgm_library/{mood}/ 目录")
print(f" 然后运行: python bgm_download.py scan 自动入库")
subprocess.run(["open", url])
def cmd_import(file_path: str, mood: str, title: str, source: str):
"""导入一个音乐文件"""
if not os.path.exists(file_path):
logger.error(f"文件不存在: {file_path}")
return
dest = LIBRARY_DIR / mood / os.path.basename(file_path)
if os.path.exists(dest):
logger.warning(f"文件已存在: {dest}")
return
import shutil
shutil.copy2(file_path, dest)
duration = get_duration(str(dest))
cfg = load_config()
cfg["库"][mood]["曲目"].append({
"文件名": dest.name,
"标题": title or dest.stem,
"时长": duration,
"源": source,
})
save_config(cfg)
print(f"✅ 导入完成: {dest.name} ({duration:.1f}s) → [{mood}]")
def get_duration(file_path: str) -> float:
"""获取音频时长(用 lib.ffmpeg —— 本机 ffprobe 实为 ffmpeg 符号链接)"""
try:
from lib.ffmpeg import probe_duration
d = probe_duration(file_path)
return round(d, 1) if d > 0 else 30.0
except Exception:
return 30.0
def load_config() -> dict:
if LIBRARY_CONFIG.exists():
with open(LIBRARY_CONFIG, encoding="utf-8") as f:
return json.load(f)
return {"库": {}}
def save_config(cfg: dict):
with open(LIBRARY_CONFIG, "w", encoding="utf-8") as f:
json.dump(cfg, f, ensure_ascii=False, indent=2)
if __name__ == "__main__":
main()
@@ -0,0 +1,164 @@
"""
AVE 07_bgm_generator — Tier 3 FFmpeg 和弦垫音
FFmpeg 和弦氛围音生成,作为离线保障 (v2 移植)
用法:
generate_chord_pad("funny", 60, "/tmp/bgm.wav")
"""
import os
import subprocess
import tempfile
from pathlib import Path
from lib.logger import get_logger
logger = get_logger("bgm_chord")
CACHE_DIR = Path(os.environ.get("AVE_CACHE_DIR",
str(Path.home() / "workbuddy-agent-os/agent-local/tools/ave/cache/bgm")))
CACHE_DIR.mkdir(parents=True, exist_ok=True)
# ── 和弦进行预设 ──
CHORD_PRESETS = {
"calm": {
"chords": [[220, 277, 330], [196, 247, 294], [261, 329, 392], [220, 277, 330]],
"bpm": 60, "lowpass": 800, "reverb_decay": 0.6, "vol_factor": 0.08,
},
"soothing": {
"chords": [[196, 233, 294], [220, 261, 330], [175, 220, 277], [196, 233, 294]],
"bpm": 55, "lowpass": 700, "reverb_decay": 0.7, "vol_factor": 0.07,
},
"happy": {
"chords": [[262, 330, 392], [330, 415, 523], [392, 494, 587], [440, 554, 659]],
"bpm": 90, "lowpass": 1200, "reverb_decay": 0.3, "vol_factor": 0.06,
},
"excited": {
"chords": [[294, 370, 440], [349, 440, 554], [392, 493, 587], [440, 554, 659]],
"bpm": 110, "lowpass": 1500, "reverb_decay": 0.2, "vol_factor": 0.05,
},
"sad": {
"chords": [[165, 196, 247], [147, 175, 220], [131, 165, 196], [147, 175, 220]],
"bpm": 50, "lowpass": 600, "reverb_decay": 0.8, "vol_factor": 0.09,
},
"mystery": {
"chords": [[165, 208, 262], [147, 185, 233], [131, 175, 220], [139, 185, 233]],
"bpm": 50, "lowpass": 500, "reverb_decay": 0.9, "vol_factor": 0.10,
},
"angry": {
"chords": [[131, 165, 196], [165, 208, 262], [147, 185, 233], [175, 220, 277]],
"bpm": 130, "lowpass": 1000, "reverb_decay": 0.4, "vol_factor": 0.06,
},
"professional": {
"chords": [[262, 330, 392], [392, 494, 587], [262, 330, 392], [330, 415, 523]],
"bpm": 70, "lowpass": 1000, "reverb_decay": 0.4, "vol_factor": 0.06,
},
"normal": {
"chords": [[220, 277, 330], [261, 329, 392], [196, 247, 294], [261, 329, 392]],
"bpm": 70, "lowpass": 900, "reverb_decay": 0.5, "vol_factor": 0.07,
},
"funny": {
"chords": [[523, 659, 784], [659, 784, 988], [784, 988, 1175], [523, 659, 784]],
"bpm": 100, "lowpass": 1400, "reverb_decay": 0.25, "vol_factor": 0.04,
},
"inspiring": {
"chords": [[262, 330, 392, 523], [294, 370, 440, 587], [330, 415, 523, 659], [392, 494, 587, 784]],
"bpm": 80, "lowpass": 1200, "reverb_decay": 0.5, "vol_factor": 0.05,
},
# ── BGM_LIBRARY 扩展 ──
"epic": {
"chords": [[262, 330, 392], [330, 415, 523], [392, 494, 587], [440, 554, 659]],
"bpm": 120, "lowpass": 1500, "reverb_decay": 0.3, "vol_factor": 0.06,
},
"tech": {
"chords": [[294, 370, 440], [349, 440, 554], [392, 493, 587], [440, 554, 659]],
"bpm": 120, "lowpass": 1800, "reverb_decay": 0.2, "vol_factor": 0.05,
},
"warm": {
"chords": [[220, 277, 330], [196, 247, 294], [261, 329, 392], [220, 277, 330]],
"bpm": 80, "lowpass": 700, "reverb_decay": 0.6, "vol_factor": 0.07,
},
"cinematic": {
"chords": [[175, 220, 277], [196, 247, 294], [220, 277, 330], [261, 329, 392]],
"bpm": 80, "lowpass": 900, "reverb_decay": 0.7, "vol_factor": 0.08,
},
}
def generate_chord_pad(mood: str, duration: float, output: str) -> str:
"""生成和弦垫音 (Tier 3)"""
preset = CHORD_PRESETS.get(mood, CHORD_PRESETS["normal"])
chord_freqs_list = preset["chords"]
bpm = preset["bpm"]
lowpass_freq = preset["lowpass"]
reverb_decay = preset["reverb_decay"]
vol_factor = preset["vol_factor"]
num_chords = len(chord_freqs_list)
chord_duration = duration / num_chords
transition = min(2.0, chord_duration * 0.3)
logger.info(f" Tier 3: {num_chords} 和弦 × {chord_duration:.1f}s, "
f"低通={lowpass_freq}Hz, 混响={reverb_decay}")
# 生成每个和弦
chord_wavs = []
for idx, freqs in enumerate(chord_freqs_list):
part_file = str(CACHE_DIR / f"_chord_{mood}_{idx}.wav")
if os.path.exists(part_file):
try: os.remove(part_file)
except: pass
expressions = []
for freq in freqs:
for detune in [0.995, 1.000, 1.005]:
expressions.append(f"sin(2*PI*{freq * detune}*t)")
expr = f"({'+'.join(expressions)})/{len(expressions)}"
subprocess.run([
"ffmpeg", "-y", "-f", "lavfi",
"-i", f"aevalsrc={expr}:s=44100:c=mono",
"-t", str(chord_duration),
"-af", f"afade=t=in:ss=0:d={transition},"
f"afade=t=out:st={chord_duration - transition}:d={transition},"
f"volume={vol_factor}",
"-acodec", "pcm_s16le", part_file,
], capture_output=True, timeout=120, check=True)
chord_wavs.append(part_file)
# 拼接
concat_file = tempfile.NamedTemporaryFile(suffix=".txt", delete=False, mode="w").name
with open(concat_file, "w", encoding="utf-8") as f:
for w in chord_wavs:
f.write(f"file '{w}'\n")
# 全局效果
if reverb_decay < 0.5:
delays = "50|80|110"
gains = f"{reverb_decay*0.6}|{reverb_decay*0.4}|{reverb_decay*0.2}"
else:
delays = "60|100|150|200"
gains = f"{reverb_decay*0.6}|{reverb_decay*0.4}|{reverb_decay*0.2}|{reverb_decay*0.1}"
try:
subprocess.run([
"ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", concat_file,
"-af", f"lowpass=f={lowpass_freq},aecho=0.8:0.7:{delays}:{gains}",
"-acodec", "pcm_s16le", output,
], capture_output=True, timeout=120, check=True)
except subprocess.CalledProcessError:
logger.warning("全局效果失败,回落无效果拼接")
subprocess.run([
"ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", concat_file,
"-acodec", "pcm_s16le", output,
], capture_output=True, check=True)
# 清理
for w in chord_wavs:
try: os.remove(w)
except: pass
try: os.remove(concat_file)
except: pass
return output
@@ -0,0 +1,273 @@
"""
AVE 07_bgm_generator — 背景音乐生成模块 v3.0
版本: v3.0 | 更新: 2026-05-06
三阶路由策略:
Tier 1: 本地 BGM 音乐库 (真实音乐,最高质量)
Tier 2: mlx-audiocraft AI 生成 (本地 MLX,按需定制)
Tier 3: FFmpeg 和弦垫音 (离线保障)
Tier 1 操作流程:
1. 从 library.json 读取 mood→曲目映射
2. 如果有匹配曲目 → 选择一首,用 FFmpeg 循环/截取到目标时长
3. 应用淡入淡出过渡
4. 混音时音量设为背景级 (0.15-0.25)
用法:
generate_bgm(mood="funny", duration=60, output="/tmp/bgm.wav")
generate_bgm(mood="happy", duration=30, output="/tmp/bgm.wav")
"""
import sys, os; sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import json
import os
import subprocess
import tempfile
from pathlib import Path
from lib.logger import get_logger
logger = get_logger("bgm")
# ── 路径 ──
LIBRARY_DIR = Path.home() / "workbuddy-agent-os" / "agent-local" / "tools" / "ave" / "cache" / "bgm_library"
LIBRARY_CONFIG = LIBRARY_DIR / "library.json"
CACHE_DIR = Path(os.environ.get("AVE_CACHE_DIR",
str(Path.home() / "workbuddy-agent-os/agent-local/tools/ave/cache/bgm")))
CACHE_DIR.mkdir(parents=True, exist_ok=True)
def generate_bgm(
mood: str = "normal",
duration: float = 30.0,
output: str = "bgm.wav",
pixabay_key: str = "",
style: str = "",
use_mlx: bool = False,
) -> str:
"""
三阶背景音乐生成 v3.0
参数:
mood: 情绪类型
duration: 目标时长(秒)
output: 输出路径
pixabay_key: (保留,暂未用)
style: 风格别名
use_mlx: 是否尝试 mlx-audiocraft (需提前安装)
返回:
生成的 BGM 文件路径
"""
if style and not mood:
mood = _style_to_mood(style)
effective_mood = mood or "normal"
logger.info(f"BGM v3: mood={effective_mood}, duration={duration}s, output={output}")
# ── Tier 1: 音乐库 ──
result = _try_library(effective_mood, duration, output)
if result:
logger.info(f"✅ Tier 1 (音乐库) → {output}")
return result
# ── Tier 2: mlx-audiocraft (按需) ──
if use_mlx:
result = _try_mlx_audiocraft(effective_mood, duration, output)
if result:
logger.info(f"✅ Tier 2 (mlx-audiocraft) → {output}")
return result
logger.warning("mlx-audiocraft 回落 → Tier 3")
# ── Tier 3: FFmpeg 和弦垫音 ──
result = _chord_pad(effective_mood, duration, output)
logger.info(f"✅ Tier 3 (和弦垫音) → {output}")
return result
def _try_library(mood: str, duration: float, output: str) -> str | None:
"""Tier 1: 从本地音乐库载入 BGM 并匹配时长"""
if not LIBRARY_CONFIG.exists():
logger.info(" library.json 不存在,跳过 Tier 1")
return None
with open(LIBRARY_CONFIG, encoding="utf-8") as f:
lib = json.load(f)
tracks = lib.get("库", {}).get(mood, {}).get("曲目", [])
valid = [t for t in tracks if t.get("文件名")]
if not valid:
logger.info(f" [{mood}] 库中无曲目")
return None
# 选择曲目: 优先选最接近目标时长的,没有完美匹配就选最短的
chosen = min(valid, key=lambda t: abs((t.get("时长") or 30) - duration))
track_file = LIBRARY_DIR / mood / chosen["文件名"]
if not track_file.exists():
logger.warning(f" 曲目文件不存在: {track_file}")
return None
track_duration = chosen.get("时长", 30)
title = chosen.get("标题", chosen["文件名"])
logger.info(f" [{mood}] 选用: {title} ({track_duration:.1f}s) → 适配 {duration:.0f}s")
os.makedirs(os.path.dirname(output) or ".", exist_ok=True)
# FFmpeg 循环/截取
if track_duration >= duration:
# 截取前段
cmd = [
"ffmpeg", "-y",
"-i", str(track_file),
"-t", str(duration),
"-af", "afade=t=in:d=1,afade=t=out:st={}:d=2".format(max(0, duration - 2)),
"-acodec", "pcm_s16le",
output,
]
else:
# 循环补齐
loops = int(duration / track_duration) + 1
cmd = [
"ffmpeg", "-y",
"-stream_loop", str(loops),
"-i", str(track_file),
"-t", str(duration),
"-af", "afade=t=in:d=1,afade=t=out:st={}:d=2".format(max(0, duration - 2)),
"-acodec", "pcm_s16le",
output,
]
try:
subprocess.run(cmd, capture_output=True, check=True, timeout=30)
return output
except subprocess.CalledProcessError as e:
logger.warning(f" FFmpeg 处理失败: {e.stderr[:200]}")
return None
def _try_mlx_audiocraft(mood: str, duration: float, output: str) -> str | None:
"""Tier 2: 使用 mlx-audiocraft 生成音乐 (需 pip install mlx-audiocraft)
注意事项:
- 国内用户需设置 HF_ENDPOINT=https://hf-mirror.com 下载模型
- 首次运行会下载 ~300MB 模型 (musicgen-small)
- 模型缓存后二次加载约 15s
- M1 上 10s 音乐生成约 46s
"""
try:
import mlx_audiocraft
except ImportError:
logger.info(" mlx-audiocraft 未安装,跳过 Tier 2")
return None
# mood→prompt 映射 (与 BGM_LIBRARY.md 同步)
prompts = {
"calm": "soft ambient piano, gentle pad, nature sounds, 60 BPM, no percussion, no vocals",
"soothing": "warm ambient drone, slow strings, peaceful atmosphere, 50 BPM, no vocals",
"happy": "bright ukulele, cheerful melody, acoustic guitar, 90 BPM, no vocals",
"excited": "upbeat electronic, driving beat, synth pads, 120 BPM, no vocals",
"sad": "melancholic piano, slow strings, emotional, 55 BPM, no vocals",
"mystery": "dark ambient, deep synth pad, suspenseful, 60 BPM, no vocals",
"angry": "intense orchestral, heavy drums, dramatic, 110 BPM, no vocals",
"professional": "corporate soft jazz, clean piano, polished, 75 BPM, no vocals",
"funny": "playful xylophone, quirky light music, cartoon style, 100 BPM, no vocals",
"inspiring": "uplifting cinematic, building strings, triumphant, 85 BPM, no vocals",
"normal": "soft background music, gentle, ambient, 70 BPM, no vocals",
# ── BGM_LIBRARY.md 扩展 ──
"epic": "epic orchestral, brass and strings, powerful drums, triumphant, building tension, 120 BPM, no vocals",
"tech": "electronic futuristic, synth pads, pulsing beat, modern, innovative, 120 BPM, no vocals",
"warm": "warm acoustic guitar, soft piano, gentle, cozy atmosphere, 80 BPM, no vocals",
"cinematic": "cinematic ambient, orchestral swells, emotional strings, majestic, 80 BPM, no vocals",
"documentary": "documentary style, soft piano, ambient strings, professional, serious, 90 BPM, no vocals",
}
prompt = prompts.get(mood, prompts["normal"])
logger.info(f" mlx-audiocraft: {prompt}")
try:
from mlx_audiocraft import MusicGen
import os as _os
# 设置国内镜像 (如果没设且连不上 huggingface)
if "HF_ENDPOINT" not in _os.environ:
_os.environ["HF_ENDPOINT"] = "https://hf-mirror.com"
# 加载模型 (会缓存,第二次更快)
gen_duration = min(duration, 15) # 音乐生成最长 15s
model = MusicGen.get_pretrained("facebook/musicgen-small")
# 生成
import mlx.core as mx
wav = model.generate(
descriptions=[prompt],
progress=False,
)
# wav 是 mlx.core.array, shape=(1, samples), float32, 32000Hz
audio = wav[0] # shape: (samples,)
sample_rate = model.sample_rate # 32000
# 转为 numpy
import numpy as np
audio_np = np.array(audio, dtype=np.float32)
# 保存为 raw float -> FFmpeg 转 WAV (避免 soundfile/scipy 依赖问题)
raw_path = str(CACHE_DIR / f"_mlx_raw_{mood}.f32")
audio_np.tofile(raw_path)
# 用 FFmpeg 转 44.1kHz WAV 并调整时长
if duration <= gen_duration:
# 截取
subprocess.run([
"ffmpeg", "-y",
"-f", "f32le", "-ar", str(sample_rate), "-ac", "1",
"-i", raw_path,
"-t", str(duration),
"-ar", "44100",
"-af", "afade=t=in:d=1,afade=t=out:st={}:d=2".format(max(0, duration - 2)),
"-acodec", "pcm_s16le", output,
], capture_output=True, check=True, timeout=30)
else:
# 循环
loops = int(duration / gen_duration) + 1
subprocess.run([
"ffmpeg", "-y",
"-stream_loop", str(loops),
"-f", "f32le", "-ar", str(sample_rate), "-ac", "1",
"-i", raw_path,
"-t", str(duration),
"-ar", "44100",
"-af", "afade=t=in:d=1,afade=t=out:st={}:d=2".format(max(0, duration - 2)),
"-acodec", "pcm_s16le", output,
], capture_output=True, check=True, timeout=30)
# 清理
try: _os.remove(raw_path)
except: pass
return output
except Exception as e:
logger.warning(f" mlx-audiocraft 失败: {e}")
return None
def _chord_pad(mood: str, duration: float, output: str) -> str:
"""Tier 3: FFmpeg 和弦垫音 (v2 保留)"""
from .chord_pad import generate_chord_pad
return generate_chord_pad(mood, duration, output)
def _style_to_mood(style: str) -> str:
s = style.lower()
if "upbeat" in s or "playful" in s: return "happy"
if "cinematic" in s or "dramatic" in s: return "mystery"
if "jazz" in s or "corporate" in s: return "professional"
if "nature" in s or "ambient" in s: return "calm"
if "funny" in s or "cartoon" in s: return "funny"
if "inspiring" in s or "motivational" in s: return "inspiring"
return "normal"
def get_available_moods() -> list[str]:
from .chord_pad import CHORD_PRESETS
return list(CHORD_PRESETS.keys())
@@ -0,0 +1,232 @@
"""
capabilities — 视频工厂能力注册表加载器
==========================================
「能力真相源」的读写接口。全项目通过这里查询能力/供应商/参数默认值。
用法:
from capabilities import CapabilityRegistry
reg = CapabilityRegistry()
caps = reg.all() # 全部能力
cap = reg.get("tts_generate") # 单个能力
reg.set_default("tts_generate", provider="aliyun") # 改默认供应商(写回 yaml)
reg.set_param_default("tts_generate", "speed", 1.2) # 改参数默认值
配置状态:provider 的 api_key_ref 指向「🔑 API 配置」管理的字段,
本模块只**检查是否已配置**,不读取密钥内容。
"""
from __future__ import annotations
import logging
import os
from pathlib import Path
from typing import Any
import yaml
log = logging.getLogger("capabilities")
SCRIPTS_DIR = Path(__file__).resolve().parent.parent # 09_ave/scripts/
AVE_DIR = SCRIPTS_DIR.parent # 09_ave/
REGISTRY_PATH = AVE_DIR / "config" / "capabilities.yaml"
# agent-local 配置中心(密钥状态查询用)
_AGENT_LOCAL = Path(os.environ.get("AGENT_LOCAL") or (AVE_DIR.parents[2] / "agent-local"))
_LOCAL_CFG = _AGENT_LOCAL / "tools" / "ave" / "config" / "local.yaml"
class CapabilityRegistry:
"""能力注册表(yaml 读写 + 查询 + 密钥状态)"""
def __init__(self, path: Path | None = None):
self.path = Path(path) if path else REGISTRY_PATH
self._data: dict = {}
self.load()
# ── 读写 ──────────────────────────────────────────
def load(self) -> dict:
if not self.path.exists():
log.warning(" ⚠️ 能力注册表不存在: %s", self.path)
self._data = {"version": "0", "categories": {}, "capabilities": []}
return self._data
try:
self._data = yaml.safe_load(self.path.read_text(encoding="utf-8")) or {}
except Exception as exc:
log.error(" ❌ 能力注册表解析失败: %s", exc)
self._data = {"version": "0", "categories": {}, "capabilities": []}
return self._data
def save(self) -> bool:
"""写回 yaml(保留可读格式)"""
try:
self._data["updated"] = _today()
with self.path.open("w", encoding="utf-8") as f:
yaml.safe_dump(
self._data, f, allow_unicode=True, sort_keys=False,
default_flow_style=False, width=120,
)
log.info(" 💾 能力注册表已保存: %s", self.path.name)
return True
except Exception as exc:
log.error(" ❌ 能力注册表保存失败: %s", exc)
return False
# ── 查询 ──────────────────────────────────────────
def all(self) -> list[dict]:
return list(self._data.get("capabilities") or [])
def categories(self) -> dict:
return dict(self._data.get("categories") or {})
def get(self, cap_id: str) -> dict | None:
for c in self.all():
if c.get("id") == cap_id:
return c
return None
def providers_of(self, cap_id: str) -> list[dict]:
cap = self.get(cap_id)
return list(cap.get("providers") or []) if cap else []
def by_category(self) -> dict[str, list[dict]]:
out: dict[str, list[dict]] = {}
for c in self.all():
out.setdefault(c.get("category", "other"), []).append(c)
return out
# ── 修改(S1 的编辑能力)──────────────────────────
def set_default(self, cap_id: str, provider: str | None = None,
model: str | None = None) -> bool:
"""改默认供应商 / 模型"""
cap = self.get(cap_id)
if not cap:
return False
defaults = cap.setdefault("defaults", {})
if provider:
valid = [p.get("id") for p in (cap.get("providers") or [])]
if provider not in valid:
log.warning(" ⚠️ 供应商不在列表中: %s(可用: %s)", provider, valid)
return False
defaults["provider"] = provider
# 同步 providers 的 default 标记
for p in cap.get("providers") or []:
p["default"] = (p.get("id") == provider)
if model:
defaults["model"] = model
return self.save()
def set_param_default(self, cap_id: str, param: str, value: Any) -> bool:
"""改参数默认值(option 级默认 + defaults 同步)"""
cap = self.get(cap_id)
if not cap:
return False
schema = cap.get("params_schema") or {}
if param not in schema:
log.warning(" ⚠️ 参数不存在: %s.%s", cap_id, param)
return False
spec = schema[param]
# 范围校验
if spec.get("type") == "number":
lo, hi = spec.get("min"), spec.get("max")
try:
value = float(value)
if lo is not None and value < lo:
value = lo
if hi is not None and value > hi:
value = hi
if float(value).is_integer():
value = int(value)
except (TypeError, ValueError):
return False
elif spec.get("type") == "boolean":
value = bool(value)
elif spec.get("type") == "enum":
if value not in (spec.get("options") or []):
log.warning(" ⚠️ 枚举值非法: %s", value)
return False
schema[param]["default"] = value
cap.setdefault("defaults", {})[param] = value
return self.save()
# ── 密钥配置状态(不读密钥内容)────────────────────
def key_configured(self, api_key_ref: str) -> bool | None:
"""检查 api_key_ref 指向的密钥是否已配置。None = 本地能力(无需密钥)"""
if not api_key_ref:
return None
if not _LOCAL_CFG.exists():
return False
try:
cfg = yaml.safe_load(_LOCAL_CFG.read_text(encoding="utf-8")) or {}
except Exception:
return False
node: Any = cfg
for part in api_key_ref.split("."):
if isinstance(node, dict) and part in node:
node = node[part]
else:
return False
return bool(str(node or "").strip())
def to_dict(self) -> dict:
"""供前端消费的完整结构(含密钥配置状态)"""
cats = self.categories()
caps = []
for c in self.all():
item = dict(c)
# 供应商密钥状态(兼容 api_key_ref / key_ref 两种字段名)
for p in item.get("providers") or []:
p["key_configured"] = self.key_configured(
p.get("api_key_ref") or p.get("key_ref") or "")
caps.append(item)
return {
"version": self._data.get("version", ""),
"updated": self._data.get("updated", ""),
"categories": [
{"id": k, **v} for k, v in sorted(
cats.items(), key=lambda kv: kv[1].get("order", 99))
],
"capabilities": caps,
"total": len(caps),
"by_status": _count_by(caps, "status"),
"by_category": _count_by(caps, "category"),
}
# ── 修复/维护 ─────────────────────────────────────
def remove_legacy_dirs(self) -> list[str]:
"""列出可归档的编号空壳目录(01-07)—— 不自动删除,仅报告"""
dead = []
for d in sorted(SCRIPTS_DIR.iterdir()):
if d.is_dir() and d.name[:2].isdigit() and d.name[2:3] == "_":
init = d / "__init__.py"
if init.exists() and init.stat().st_size == 0:
dead.append(d.name)
return dead
def _count_by(items: list[dict], field: str) -> dict:
out: dict[str, int] = {}
for it in items:
k = str(it.get(field) or "unknown")
out[k] = out.get(k, 0) + 1
return out
def _today() -> str:
from datetime import date
return date.today().isoformat()
# 便捷单例
_registry: CapabilityRegistry | None = None
def get_registry(reload: bool = False) -> CapabilityRegistry:
global _registry
if _registry is None or reload:
_registry = CapabilityRegistry()
return _registry
@@ -0,0 +1,350 @@
"""百炼(DashScope)视频生成 —— 首尾帧 KF2V / 多图参考 R2V
为什么单独一个模块:可灵走 `kling_video.py`(channel=kling),
百炼走这里(channel=dashscope)—— 两家接口完全不同,不能混。
价格对比(`config/video_models.yaml`):
wan2.2-kf2v-flash ¥0.1/秒(480P) ← 首尾帧专用,最省
wan2.6-r2v-flash ¥0.3/秒(720P) ← 多图参考(角色+场景+首帧)
kling-v3 ¥0.6/秒(720P) ← 现行默认
→ 同样 4 镜 5 秒:可灵 ¥12,kf2v ¥2。
调用范式(与 `material_producer/wan2_2/wan2_2.py` 一致,四步):
① 上传图片 → oss:// URL(每张都要)
② POST video-synthesis(X-DashScope-Async: enable)
③ GET /api/v1/tasks/{task_id} 轮询
④ 下载 video_url
字段(来自千问AI平台 OpenAPI spec,见 docs/pipeline_research/03):
KF2V: input.first_frame_url + input.last_frame_url + input.prompt
R2V : input.media[] = [{type: first_frame|reference_image|reference_video, url}]
+ input.prompt
"""
from __future__ import annotations
import base64
import json
import logging
import os
import time
from pathlib import Path
import httpx
import yaml
log = logging.getLogger("ave.dashscope_video")
# ── 端点 ────────────────────────────────────────────────────
# ⚠️ 两个模型走**不同端点**(据官方 OpenAPI 规格,见 docs/pipeline_research/03):
# KF2V(首尾帧) → /services/aigc/image2video/video-synthesis
# R2V (多图参考)→ /services/aigc/video-generation/video-synthesis
BASE = "https://dashscope.aliyuncs.com"
UPLOAD_API = f"{BASE}/api/v1/uploads"
SUBMIT_API_KF2V = f"{BASE}/api/v1/services/aigc/image2video/video-synthesis"
SUBMIT_API_R2V = f"{BASE}/api/v1/services/aigc/video-generation/video-synthesis"
SUBMIT_API = SUBMIT_API_R2V # 兼容旧引用
TASK_API = f"{BASE}/api/v1/tasks/{{task_id}}"
# ⚠️ 所有 httpx 调用都要 trust_env=False —— macOS 系统代理会让
# 国内 API 时通时不通(本项目踩过多次,见 docs/参数速查.md)
_LOCAL_YAML = Path.home() / "workbuddy-agent-os" / "agent-local" / "tools" / "ave" / "config" / "local.yaml"
def api_key() -> str:
"""读百炼 api_key(agent-local,不进 git)"""
env = os.environ.get("DASHSCOPE_API_KEY")
if env:
return env
try:
d = yaml.safe_load(_LOCAL_YAML.read_text(encoding="utf-8")) or {}
return str((d.get("aliyun") or {}).get("api_key") or "")
except Exception as e:
log.warning("读 local.yaml 失败: %s", e)
return ""
def available() -> bool:
return bool(api_key())
# ── ① 上传 ──────────────────────────────────────────────────
def _mime(p: str) -> str:
return {"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg",
"webp": "image/webp", "bmp": "image/bmp",
"mp4": "video/mp4",
"mp3": "audio/mpeg", "wav": "audio/wav", "m4a": "audio/mp4"}.get(
Path(p).suffix.lower().lstrip("."), "application/octet-stream")
def upload_file(path: str, model: str, key: str = "") -> str:
"""上传到百炼临时存储 → oss:// URL
已上传过(同名 + 同模型)会复用,避免重复上传。
"""
key = key or api_key()
p = Path(path)
if not p.exists():
raise FileNotFoundError(f"文件不存在: {path}")
r = httpx.get(UPLOAD_API, params={"action": "getPolicy", "model": model},
headers={"Authorization": f"Bearer {key}"}, timeout=20, trust_env=False)
r.raise_for_status()
d = r.json()["data"]
oss_key = f"{d['upload_dir']}/{p.name}"
with open(p, "rb") as f:
files = {
"OSSAccessKeyId": (None, d["oss_access_key_id"]),
"policy": (None, d["policy"]),
"Signature": (None, d["signature"]),
"key": (None, oss_key),
"x-oss-object-acl": (None, d["x_oss_object_acl"]),
"x-oss-forbid-overwrite": (None, d["x_oss_forbid_overwrite"]),
"success_action_status": (None, "200"),
"file": (p.name, f, _mime(str(p))),
}
up = httpx.post(d["upload_host"], files=files, timeout=180, trust_env=False)
up.raise_for_status()
return f"oss://{oss_key}"
def _as_url(v: str, model: str, key: str) -> str:
"""本地路径 → oss:// ;已是 http(s)/oss:// /data: 则原样返回"""
if not v:
return ""
s = str(v)
if s.startswith(("http://", "https://", "oss://", "data:")):
return s
return upload_file(s, model, key)
# ── ② 提交 ──────────────────────────────────────────────────
def _submit(payload: dict, key: str, endpoint: str = "") -> str:
headers = {
"Authorization": f"Bearer {key}",
"X-DashScope-Async": "enable",
"X-DashScope-OssResourceResolve": "enable",
"Content-Type": "application/json",
}
r = httpx.post(endpoint or SUBMIT_API_R2V, headers=headers, json=payload,
timeout=40, trust_env=False)
if r.status_code >= 400:
raise RuntimeError(f"百炼提交失败 HTTP {r.status_code}: {r.text[:300]}")
out = (r.json() or {}).get("output") or {}
tid = out.get("task_id")
if not tid:
raise RuntimeError(f"百炼未返回 task_id: {json.dumps(r.json(), ensure_ascii=False)[:300]}")
return tid
def submit_kf2v(first_frame: str, last_frame: str, prompt: str = "", *,
model: str = "wan2.2-kf2v-flash", resolution: str = "480P",
negative_prompt: str = "", prompt_extend: bool = True,
seed: int | None = None, template: str = "",
key: str = "") -> str:
"""首尾帧生视频 —— 给「首帧 + 尾帧」,模型在两帧之间插值
⚠️ 官方规格(docs/pipeline_research/03 §二):
· 端点 = /services/aigc/image2video/video-synthesis(**不是** video-generation)
· duration **固定 5 秒,不可修改** → 不传该参数
· 输出宽高比跟随 first_frame_url
· 用了 template(特效模板)时只需传 first_frame_url
"""
key = key or api_key()
if not key:
raise RuntimeError("未配置百炼 key(agent-local/tools/ave/config/local.yaml → aliyun.api_key)")
if not last_frame:
raise ValueError("KF2V 必须同时给首帧和尾帧")
inp = {"first_frame_url": _as_url(first_frame, model, key)}
if not template:
inp["last_frame_url"] = _as_url(last_frame, model, key)
if prompt:
inp["prompt"] = prompt
if negative_prompt:
inp["negative_prompt"] = negative_prompt[:500]
params: dict = {"resolution": resolution, "prompt_extend": bool(prompt_extend)}
if template:
params["template"] = template
if seed is not None:
params["seed"] = int(seed)
log.info("KF2V 提交:%s · %s · 固定5s · %s", model, resolution,
"模板" if template else "首尾帧")
return _submit({"model": model, "input": inp, "parameters": params}, key, SUBMIT_API_KF2V)
def submit_r2v(media: list[dict], prompt: str = "", *,
model: str = "wan2.6-r2v-flash", resolution: str = "720P",
duration: int = 5, key: str = "") -> str:
"""多图参考生视频
media: [{"type": "first_frame"|"reference_image"|"reference_video", "url": 本地路径或URL}]
最多 5 个(config 里的 limits.max_reference_images)
"""
key = key or api_key()
if not key:
raise RuntimeError("未配置百炼 key")
if not media:
raise ValueError("R2V 至少要给 1 个 media 元素")
if not (prompt or "").strip():
# 官方规格:prompt 为 ★必填(「期望视频内容」)—— 不传会直接失败
raise ValueError("R2V 的 prompt 是必填项(描述期望的视频内容),不能为空")
items = []
for m in media[:5]:
u = _as_url(m.get("url") or "", model, key)
if not u:
continue
it = {"type": m.get("type") or "reference_image", "url": u}
if m.get("reference_voice"):
it["reference_voice"] = m["reference_voice"]
items.append(it)
if not items:
raise ValueError("media 里没有可用的 url")
payload = {
"model": model,
"input": {"media": items},
"parameters": {"resolution": resolution, "duration": duration},
}
if prompt:
payload["input"]["prompt"] = prompt
log.info("R2V 提交:%s · %s · %d 个参考", model, resolution, len(items))
return _submit(payload, key)
# ── ③ 轮询 ──────────────────────────────────────────────────
def poll(task_id: str, *, interval: int = 10, timeout: int = 900,
key: str = "", progress_cb=None) -> str:
"""轮询直到完成 → 返回视频 URL
兼容两种结果结构:
output.video_url (KF2V / R2V 新结构)
output.results.video_url (wan2.2-s2v 旧结构)
"""
key = key or api_key()
headers = {"Authorization": f"Bearer {key}", "X-DashScope-OssResourceResolve": "enable"}
start = time.time()
while time.time() - start < timeout:
r = httpx.get(TASK_API.format(task_id=task_id), headers=headers,
timeout=40, trust_env=False)
r.raise_for_status()
out = (r.json() or {}).get("output") or {}
st = out.get("task_status")
el = int(time.time() - start)
if st == "SUCCEEDED":
url = (out.get("video_url")
or (out.get("results") or {}).get("video_url")
or ((out.get("results") or {}).get("videos") or [{}])[0].get("url"))
if not url:
raise RuntimeError(f"任务成功但没有视频 URL:{json.dumps(out, ensure_ascii=False)[:300]}")
if progress_cb:
progress_cb(f"✅ 生成完成({el}s)")
return url
if st in ("FAILED", "CANCELED", "UNKNOWN"):
code = out.get("code") or "?"
msg = out.get("message") or "?"
raise RuntimeError(f"百炼任务失败 [{code}]: {msg}")
if progress_cb:
progress_cb(f"⏳ 百炼生成中… {el}s({st})")
time.sleep(interval)
raise TimeoutError(f"百炼任务超时(>{timeout}s)")
# ── ④ 下载 ──────────────────────────────────────────────────
def download(url: str, dest: str) -> str:
Path(dest).parent.mkdir(parents=True, exist_ok=True)
with httpx.stream("GET", url, timeout=300, trust_env=False,
follow_redirects=True) as r:
r.raise_for_status()
with open(dest, "wb") as f:
for chunk in r.iter_bytes(1 << 16):
f.write(chunk)
return dest
# ── 一步到位 ────────────────────────────────────────────────
def generate_kf2v(first_frame: str, last_frame: str, dest: str, prompt: str = "",
*, model: str = "wan2.2-kf2v-flash", resolution: str = "480P",
progress_cb=None) -> str:
"""首尾帧一步到位(提交 → 轮询 → 下载)
⚠️ duration 固定 5 秒(官方规格),所以这里不提供 duration 参数。
"""
tid = submit_kf2v(first_frame, last_frame, prompt, model=model, resolution=resolution)
if progress_cb:
progress_cb(f"📤 已提交(task {tid})")
url = poll(tid, progress_cb=progress_cb)
download(url, dest)
return dest
def generate_r2v(media: list[dict], dest: str, prompt: str = "",
*, model: str = "wan2.6-r2v-flash", resolution: str = "720P",
duration: int = 5, progress_cb=None) -> str:
tid = submit_r2v(media, prompt, model=model, resolution=resolution, duration=duration)
if progress_cb:
progress_cb(f"📤 已提交(task {tid})")
url = poll(tid, progress_cb=progress_cb)
download(url, dest)
return dest
# ══════════════════════════════════════════════════════════
# LivePortrait(灵动人像)—— 数字人播报,¥0.02/秒
#
# 人物肖像图 + 语音音频 → 口型同步的播报视频
# ★ 输入正好是我们的现有资产:角色定妆照 + TTS 配音
# ⚠️ 只适合「口播/播报」类镜头(talking_head),有动作的镜头不适用
# ══════════════════════════════════════════════════════════
LIVEPORTRAIT_MODEL = "liveportrait"
def submit_liveportrait(image: str, audio: str, *, template_id: str = "calm",
eye_move_freq: float | None = None,
model: str = LIVEPORTRAIT_MODEL, key: str = "") -> str:
"""数字人播报任务提交
image : 人物肖像图(本地路径或 URL)—— 建议用角色定妆照
audio : 驱动音频(本地路径或 URL)—— TTS 配音;1 秒 ~ 3 分钟
template_id : normal(中等幅度)| calm(小幅度·推荐播报)| active(大幅度·演唱)
"""
key = key or api_key()
if not key:
raise RuntimeError("未配置百炼 key")
if not (image and audio):
raise ValueError("liveportrait 必须同时给 image 和 audio")
inp = {
"image_url": _as_url(image, model, key),
"audio_url": _as_url(audio, model, key),
}
params: dict = {"template_id": template_id if template_id in
("normal", "calm", "active") else "calm"}
if eye_move_freq is not None:
params["eye_move_freq"] = float(eye_move_freq)
log.info("LivePortrait 提交:模板=%s", params["template_id"])
# 与 KF2V 同端点(官方文档:image2video/video-synthesis)
return _submit({"model": model, "input": inp, "parameters": params},
key, SUBMIT_API_KF2V)
def generate_liveportrait(image: str, audio: str, dest: str, *,
template_id: str = "calm", progress_cb=None) -> str:
"""数字人播报一步到位(提交 → 轮询 → 下载)
⚠️ 输出时长由**音频时长**决定(不是固定 5 秒)
"""
tid = submit_liveportrait(image, audio, template_id=template_id)
if progress_cb:
progress_cb(f"📤 已提交(task {tid})")
url = poll(tid, progress_cb=progress_cb)
download(url, dest)
return dest
def estimate_cost(seconds: float, model: str = "wan2.2-kf2v-flash") -> float:
if model == LIVEPORTRAIT_MODEL:
return round(seconds * 0.02, 3)
rate = 0.1 if "kf2v" in model else 0.3
return round(seconds * rate, 2)
@@ -0,0 +1,604 @@
"""
capabilities.kling_video — 可灵(Kling)视频生成能力实现
==========================================================
支持:文生视频(text2video)/ 图生视频(image2video)+ 任务轮询 + 下载
鉴权:api_key(Bearer)优先 → access_key + secret_key(JWT HS256)回退
配置来源:agent-local/tools/ave/config/local.yaml → kling 节(或环境变量)
用途:视频工厂 S2-6「逐镜头生成」的默认引擎实现。
余额不足(code 1102)等错误会带明确提示,不会静默失败。
"""
from __future__ import annotations
import base64
import json
import logging
import os
import ssl
import time
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any, Optional
log = logging.getLogger("capabilities.kling")
DEFAULT_BASE = "https://api-beijing.klingai.com"
# ════════════════════════════════════════════════════════════
# ★ 网络统一入口:**禁用系统代理** + 自动重试
# ════════════════════════════════════════════════════════════
# 踩坑记录(2026-10-04 定位):
# macOS 开了系统代理(127.0.0.1:7892)时,urllib 会**自动走代理**,
# 而代理对可灵 API 时通时不通 → `SSL: UNEXPECTED_EOF_WHILE_READING`
# → 表现为"生成随机失败"(用户遇到的角色生成失败即此因)。
# 直连实测:3/3 次 HTTP 200、0.2~0.3 秒。
#
# 解决:所有 API 调用走**禁代理 opener** + 失败自动重试 3 次(指数退避)。
_NO_PROXY_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))
def _urlopen(req, timeout: int = 60):
"""API 调用统一入口:禁代理 + 重试 3 次(应对网络抖动)"""
last_exc = None
for attempt in range(3):
try:
return _NO_PROXY_OPENER.open(req, timeout=timeout)
except Exception as e: # URLError / SSLError / OSError / timeout
last_exc = e
if attempt < 2:
time.sleep(1.5 * (attempt + 1))
raise last_exc
# ── 配置 ───────────────────────────────────────────────
def _load_config() -> dict:
"""读 kling 配置(local.yaml 优先,环境变量覆盖)"""
cfg: dict = {}
try:
import yaml
_agent_local = Path(os.environ.get("AGENT_LOCAL") or (Path.home() / "workbuddy-agent-os" / "agent-local"))
p = _agent_local / "tools" / "ave" / "config" / "local.yaml"
if p.exists():
raw = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
cfg.update(raw.get("kling") or {})
except Exception as e:
log.debug("读取 kling 配置失败: %s", e)
if os.environ.get("KLING_API_KEY"):
cfg["api_key"] = os.environ["KLING_API_KEY"]
cfg.setdefault("base_url", DEFAULT_BASE)
return cfg
class KlingError(RuntimeError):
"""可灵调用错误(含 code / message,便于按余额不足等场景提示)"""
def __init__(self, message: str, code: Any = None):
super().__init__(message)
self.code = code
# ── 防人物漂移负向词(研究来源:madebysaira/CharacterConsistency 实测词表)
# 作者反馈:加上这些后多镜一致性问题显著减少(重生成率 60% → 15%)
# 用法:图生视频时随 prompt 一起传;最强的漂移词放最前
DRIFT_NEGATIVE = (
"face change, different person, another woman, another man, "
"clothing shift, outfit change, hair style change, hair length change, "
"body proportion change, extra limbs, deformed face, inconsistent lighting, "
"skin tone shift, eye color change, blurry face, morphing, identity shift, "
"reference image ignored, low face similarity"
)
# 图生视频时的参考图权重(研究:Kling 推荐 0.75-0.85;0.5 会导致身份不稳)
DEFAULT_IMAGE_CFG_SCALE = 0.8
class KlingVideoClient:
"""可灵视频生成客户端(文生视频 / 图生视频)"""
def __init__(self, config: Optional[dict] = None):
self.cfg = config or _load_config()
self.base = (self.cfg.get("base_url") or DEFAULT_BASE).rstrip("/")
self.api_key = (self.cfg.get("api_key") or "").strip()
self.access_key = (self.cfg.get("access_key") or "").strip()
self.secret_key = (self.cfg.get("secret_key") or "").strip()
@property
def available(self) -> bool:
return bool(self.api_key or (self.access_key and self.secret_key))
def _headers(self) -> dict:
if self.api_key:
return {"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json"}
# AK/SK → JWT HS256(官方方式)
import hashlib
import hmac
header = base64.urlsafe_b64encode(json.dumps(
{"alg": "HS256", "typ": "JWT"}).encode()).rstrip(b"=")
now = int(time.time())
payload = base64.urlsafe_b64encode(json.dumps(
{"iss": self.access_key, "exp": now + 1800, "nbf": now - 5}).encode()).rstrip(b"=")
signing = header + b"." + payload
sig = base64.urlsafe_b64encode(
hmac.new(self.secret_key.encode(), signing, hashlib.sha256).digest()).rstrip(b"=")
return {"Authorization": f"Bearer {(signing + b'.' + sig).decode()}",
"Content-Type": "application/json"}
def _post(self, path: str, payload: dict, _retries: int = 3) -> dict:
"""POST 请求
内置两类处理:
· **并发超限(code 1303)→ 退避重试**(官方建议:初始延迟 ≥1s)
账号并发:视频 5 / 图片 9(图片按参数 n 计),超限即触发
· 余额不足(1102)/ 鉴权失败(1101)→ 直接抛明确错误,不重试
"""
req = urllib.request.Request(self.base + path,
data=json.dumps(payload).encode("utf-8"),
headers=self._headers())
for attempt in range(max(1, _retries)):
try:
with _urlopen(req, timeout=60) as r:
return json.loads(r.read().decode("utf-8"))
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", errors="ignore")
try:
d = json.loads(body)
code, msg = d.get("code"), d.get("message", "")
except Exception:
code, msg = e.code, body[:200]
# ★ 并发超限:官方明确建议退避重试(非参数错误)
if code == 1303 or "parallel task over" in str(msg).lower():
if attempt < _retries - 1:
delay = 2.0 * (attempt + 1)
log.warning(" ⏳ 可灵并发超限(1303),%.0fs 后重试(%d/%d)",
delay, attempt + 1, _retries - 1)
time.sleep(delay)
continue
raise KlingError(
f"可灵并发超限(code 1303)—— 已重试 {_retries} 次仍失败。"
f"账号并发上限:视频 5 / 图片 9(图片按参数 n 计);"
f"请稍后再试或降低并发生成数", code)
if code == 1102 or "balance" in str(msg).lower():
raise KlingError("可灵账户余额不足(code 1102)—— 请到可灵开放平台充值后重试", code)
if code == 1101 or "auth" in str(msg).lower():
raise KlingError(f"可灵鉴权失败(code {code})—— 检查 API Key 是否正确/过期", code)
raise KlingError(f"可灵接口错误 HTTP {e.code} code={code}: {msg}", code)
except KlingError:
raise
except Exception as e:
raise KlingError(f"可灵请求失败: {e}")
def _get(self, path: str) -> dict:
req = urllib.request.Request(self.base + path, headers=self._headers())
try:
with _urlopen(req, timeout=60) as r:
return json.loads(r.read().decode("utf-8"))
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", errors="ignore")
raise KlingError(f"可灵查询失败 HTTP {e.code}: {body[:150]}")
except Exception as e:
raise KlingError(f"可灵查询失败: {e}")
# ── 任务提交 ───────────────────────────────────────
def submit_text2video(self, prompt: str, model: str = "kling-v3",
duration: str = "5", aspect_ratio: str = "9:16",
negative_prompt: str = "", cfg_scale: float = 0.5,
camera_control: Optional[dict] = None,
sound: str = "off") -> str:
"""文生视频 → task_id(camera_control:运镜控制,见 build_camera_control)
sound:**原生音频开关**("on" / "off")。实测(2026-10-04):
· off(默认)→ 输出只有视频流
· on → 输出含音频流(aac 44.1k stereo),
提示词里写对话即生成对应语音 + 环境音
"""
payload = {"model_name": model, "prompt": prompt, "duration": duration,
"aspect_ratio": aspect_ratio, "cfg_scale": cfg_scale}
if negative_prompt:
payload["negative_prompt"] = negative_prompt
if camera_control:
payload["camera_control"] = camera_control
if sound == "on":
payload["sound"] = "on"
d = self._post("/v1/videos/text2video", payload)
return self._extract_task_id(d)
def submit_image2video(self, image_path: str, prompt: str = "", model: str = "kling-v3",
duration: str = "5", tail_image_path: str = "",
cfg_scale: float = DEFAULT_IMAGE_CFG_SCALE,
camera_control: Optional[dict] = None,
negative_prompt: str = DRIFT_NEGATIVE,
sound: str = "off") -> str:
"""图生视频 → task_id(image:本地文件路径 → base64 或 URL)
⚠️ 研究结论(2026-10-03):
· **cfg_scale(参考图权重)0.75-0.85** —— 原来用 0.5 会导致人物身份不稳、
跨镜换脸。默认已提到 0.8。
· **必须传防漂移负向词** —— 否则模型容易改变脸/服装/发型。
"""
payload: dict = {"model_name": model, "duration": duration,
"cfg_scale": cfg_scale, "image": self._to_image_value(image_path)}
if prompt:
payload["prompt"] = prompt
if negative_prompt:
payload["negative_prompt"] = negative_prompt
if tail_image_path:
payload["image_tail"] = self._to_image_value(tail_image_path)
if camera_control:
payload["camera_control"] = camera_control
if sound == "on":
payload["sound"] = "on" # ★ 原生音频(见 submit_text2video 注释)
d = self._post("/v1/videos/image2video", payload)
return self._extract_task_id(d)
@staticmethod
def _to_image_value(image: str) -> str:
"""本地路径 → base64(不带前缀);http(s) 或已是 base64 则原样返回"""
if image.startswith(("http://", "https://")):
return image
p = Path(image)
if p.exists():
return base64.b64encode(p.read_bytes()).decode("utf-8")
return image
@staticmethod
def _extract_task_id(d: dict) -> str:
if d.get("code") != 0:
raise KlingError(f"可灵提交失败 code={d.get('code')}: {d.get('message')}", d.get("code"))
tid = (d.get("data") or {}).get("task_id")
if not tid:
raise KlingError(f"可灵未返回 task_id: {str(d)[:200]}")
return tid
# ── 轮询 + 下载 ────────────────────────────────────
def wait_for_video(self, task_id: str, kind: str = "text2video",
timeout: int = 600, interval: int = 10,
progress_cb=None) -> str:
"""轮询任务直至完成 → 返回视频 URL"""
path = f"/v1/videos/{kind}/{task_id}"
deadline = time.time() + timeout
while time.time() < deadline:
d = self._get(path)
data = d.get("data") or {}
status = data.get("task_status")
if status == "succeed":
videos = (data.get("task_result") or {}).get("videos") or []
if not videos:
raise KlingError("可灵任务成功但无视频输出")
return videos[0].get("url", "")
if status == "failed":
raise KlingError(f"可灵生成失败: {data.get('task_status_msg') or '未知原因'}")
if progress_cb:
progress_cb(status or "processing")
time.sleep(interval)
raise KlingError(f"可灵任务超时({timeout}s)未完成")
def download(self, url: str, dest: str | Path) -> Path:
"""下载视频到本地
可灵返回的是**带签名的完整 URL**(含 cacheKey 参数),通常**无需额外鉴权**;
若首次返回 401/403,再带鉴权头重试一次(兼容不同资源域名)。
"""
p = Path(dest)
p.parent.mkdir(parents=True, exist_ok=True)
attempts: list[dict] = [{}]
try:
headers = self._headers()
headers.pop("Content-Type", None)
attempts.append(headers)
except Exception:
pass
last_err: Exception | None = None
for hdrs in attempts:
try:
req = urllib.request.Request(url, headers=hdrs)
with _urlopen(req, timeout=300) as r, open(p, "wb") as f:
f.write(r.read())
return p
except urllib.error.HTTPError as e:
last_err = e
if e.code not in (401, 403):
break
except Exception as e:
last_err = e
break
code = getattr(last_err, "code", "?")
raise KlingError(
f"下载生成视频失败(HTTP {code})—— 请确认任务返回的 URL 完整"
"(含 ?cacheKey=... 签名参数;可灵资源有效期 30 天)"
)
# ── 一站式 ─────────────────────────────────────────
# ════════════════════════════════════════════════════════
# 账号与费用(官方接口,**免费**)
# ════════════════════════════════════════════════════════
def get_account_costs(self, days: int = 30) -> dict:
"""查询账号**资源包余量**(官方免费接口,QPS ≤ 1)
⚠️ 官方说明:余量统计有 **12 小时延迟**,非实时。
⚠️ 只返回资源包(含平台赠送的试用包);**现金余额需在可灵控制台查看**。
实测字段(2026-10-04):resource_pack_name / total_quantity /
remaining_quantity / resource_pack_type / status / invalid_time
"""
import time as _t
now_ms = int(_t.time() * 1000)
start_ms = now_ms - int(days) * 24 * 3600 * 1000
try:
j = self._get(f"/account/costs?start_time={start_ms}&end_time={now_ms}")
except Exception as e:
return {"packs": [], "summary": {}, "error": str(e)[:200]}
if j.get("code") != 0:
return {"packs": [], "summary": {}, "error": str(j.get("message", ""))[:200]}
packs = []
for p in (j.get("data") or {}).get("resource_pack_subscribe_infos") or []:
total = float(p.get("total_quantity") or 0)
left = float(p.get("remaining_quantity") or 0)
packs.append({
"name": p.get("resource_pack_name", ""),
"type": p.get("resource_pack_type", ""),
"total": total,
"remaining": left,
"used": round(total - left, 2),
"used_pct": round((total - left) / total * 100, 1) if total else 0,
"status": p.get("status", ""),
"expire_at": p.get("invalid_time"),
})
summary: dict = {}
for p in packs:
nm = p["name"]
key = "video" if "视频" in nm else ("image" if ("图像" in nm or "图片" in nm) else "other")
s = summary.setdefault(key, {"total": 0.0, "remaining": 0.0, "used": 0.0, "packs": 0})
s["total"] += p["total"]
s["remaining"] += p["remaining"]
s["used"] += p["used"]
s["packs"] += 1
return {"packs": packs, "summary": summary, "days": days,
"note": "官方余量有 12 小时延迟;现金余额需在可灵控制台查看"}
# ════════════════════════════════════════════════════════
# 视频生音效(给已有视频自动配音效 / BGM)—— ¥0.25/次
# ════════════════════════════════════════════════════════
def submit_video_to_audio(self, video_id: str = "", video_url: str = "",
sound_effect_prompt: str = "", bgm_prompt: str = "",
asmr_mode: bool = False) -> str:
"""给视频生成音效/BGM(POST /v1/audio/video-to-audio)
· 成本 ¥0.25/次
· video_id 与 video_url **二选一**(不能都填)
· video_id 仅支持 30 天内的可灵生成视频,时长 3~20 秒
· prompt ≤ 200 字符;asmr_mode=True 增强细节音效
用途:我们的成片默认**完全静音**("AI 感"的重要来源),
用此接口为镜头补真实环境音 → 显著提升真实感。
"""
if bool(video_id) == bool(video_url):
raise ValueError("video_id 与 video_url 必须二选一")
payload: dict = {}
if video_id:
payload["video_id"] = video_id
else:
payload["video_url"] = video_url
if sound_effect_prompt:
payload["sound_effect_prompt"] = sound_effect_prompt[:200]
if bgm_prompt:
payload["bgm_prompt"] = bgm_prompt[:200]
if asmr_mode:
payload["asmr_mode"] = True
d = self._post("/v1/audio/video-to-audio", payload)
return self._extract_task_id(d)
# ════════════════════════════════════════════════════════
# 多图参考生图(主体 + 场景 + 风格 **分离**)—— ¥0.4/张
# ════════════════════════════════════════════════════════
def submit_multi_image2image(self, subject_images: list, prompt: str = "",
scene_image: str = "", style_image: str = "",
aspect_ratio: str = "9:16", n: int = 1,
model: str = "kling-v2-1") -> str:
"""多图参考生图(POST /v1/images/multi-image2image)
· 成本 ¥0.4/张
· subject_images: **1~4 张主体参考图**(URL 或 base64 字符串,不加前缀)
我们的「5 视角套装」正适合喂进来(正面 + 45° + 侧面)
· scene_image: **场景参考图** ← 解决「画面没有环境」的正解
· style_image: 风格参考图
· prompt ≤ 2500 字符
· aspect_ratio: 16:9 / 9:16 / 1:1 / 4:3 / 3:4 / 3:2 / 2:3 / 21:9
⚠️ **实测约束(2026-10-04 探测)**:
① **只支持 model=kling-v2-1**(kling-v3 / kling-image-o1 报 model is not supported)
② **主体 + 场景 + 风格三组图「合计」至少 2 张**
solo 1 张会报错;最小组合 = 1 主体 + 1 场景,或 2 张主体(正面+侧面)
⚠️ 并发按 n 计(账号图片并发 9)→ 建议 n=1 串行调用,避免 1303
用途(回答「参考图该用空白背景还是场景」):
官方接口把主体与场景**分开** —— 角色图只管人物,场景图只管环境,
两者不互相污染。这印证了业界结论。
"""
subs = [s for s in (subject_images or []) if s][:4]
if not subs:
raise ValueError("subject_images 至少需要 1 张主体参考图")
if len(subs) + (1 if scene_image else 0) + (1 if style_image else 0) < 2:
raise ValueError("三组图(主体/场景/风格)**合计至少 2 张**:"
"请补一张场景图,或提供 2 张以上主体图(如正面+侧面)")
payload: dict = {
"model_name": model,
"subject_image_list": [{"subject_image": s} for s in subs],
"aspect_ratio": aspect_ratio,
"n": int(n),
}
if prompt:
payload["prompt"] = prompt[:2500]
if scene_image:
payload["scene_image"] = scene_image
if style_image:
payload["style_image"] = style_image
d = self._post("/v1/images/multi-image2image", payload)
return self._extract_task_id(d)
# ── 通用:把可灵 task_id 记到 sidecar 文件(供「视频生音效」用)──
@staticmethod
def save_task_id(dest, task_id: str) -> None:
"""把 task_id 写到 `{dest}.taskid`(video-to-audio 只认可灵 video_id)"""
try:
p = Path(dest)
p.with_suffix(p.suffix + ".taskid").write_text(str(task_id), encoding="utf-8")
except Exception as e:
log.debug("记录 task_id 失败: %s", e)
@staticmethod
def load_task_id(video_path) -> str:
"""读取镜头对应的可灵 task_id(无则返回空串)"""
try:
p = Path(video_path)
f = p.with_suffix(p.suffix + ".taskid")
return f.read_text(encoding="utf-8").strip() if f.exists() else ""
except Exception:
return ""
def wait_for_audio(self, task_id: str, timeout: int = 600) -> str:
"""等待「视频生音效」任务完成 → 返回音频 URL(通常 30~90 秒)"""
import time as _t
deadline = _t.time() + timeout
last = ""
while _t.time() < deadline:
try:
d = self._get(f"/v1/audio/video-to-audio/{task_id}")
except Exception as e:
log.debug("音效任务查询失败(重试): %s", str(e)[:80])
_t.sleep(8)
continue
data = d.get("data") or {}
st = data.get("task_status", "")
if st == "succeed":
r = data.get("task_result") or {}
urls = []
for key in ("sound_effects", "bgm", "audios"):
v = r.get(key)
if isinstance(v, list):
urls += [x.get("url") for x in v if isinstance(x, dict) and x.get("url")]
elif isinstance(v, dict) and v.get("url"):
urls.append(v["url"])
if not urls:
for key in ("video_url", "url"):
if r.get(key):
urls.append(r[key])
if urls:
return urls[0]
raise KlingError(f"音效任务成功但无音频 URL: {str(r)[:200]}")
if st in ("failed", "canceled"):
raise KlingError(f"音效任务 {st}: {str(data.get('task_status_msg') or '')[:200]}")
if st != last:
last = st
log.info(" 🎧 音效任务 %s: %s", task_id[:8], st)
_t.sleep(8)
raise KlingError(f"音效任务超时(>{timeout}s)")
def generate_sfx_for_video(self, video_id: str, dest: str | Path,
sound_effect_prompt: str = "", bgm_prompt: str = "",
asmr_mode: bool = False, timeout: int = 600) -> Path:
"""给指定镜头生成音效并下载(**¥0.25/次**)
用途:我们的成片默认**完全静音**("AI 感"的主要来源之一)。
给镜头补真实环境音/BGM → 混进最终音轨后真实感显著提升。
⚠️ video_id 必须是**可灵平台的任务 ID**(生成镜头时记下的 task_id)
"""
tid = self.submit_video_to_audio(video_id=video_id,
sound_effect_prompt=sound_effect_prompt,
bgm_prompt=bgm_prompt, asmr_mode=asmr_mode)
log.info(" 🎧 音效任务已提交: %s(¥0.25)", tid[:12])
url = self.wait_for_audio(tid, timeout=timeout)
self.download(url, dest)
log.info(" ✅ 音效已下载: %s", Path(dest).name)
return Path(dest)
def generate_video(self, prompt: str, dest: str | Path,
image_path: str = "", duration: str = "5",
aspect_ratio: str = "9:16", model: str = "kling-v3",
timeout: int = 600, progress_cb=None,
vfx_type: str = "", vfx_params: Optional[dict] = None,
tail_image_path: str = "", sound: str = "off") -> Path:
"""生成一个镜头片段(有参考图走图生视频,否则文生视频)
vfx_type:特效类型(orbit / bullet_time / slow_motion / focus_lock …)
→ 自动转成可灵 `camera_control` 运镜参数
tail_image_path:尾帧图 —— **首尾帧衔接**用(可灵 image_tail):
本镜接上一镜时,把上一镜的尾帧传进来,生成结果会从该尾帧收束
sound:**原生音频**("on"/"off",实测可用)。开着它时提示词里写对话,
会一次生成「带语音的视频」→ 音画天然同步,无需后期 TTS / 对口型。
"""
cam = self.build_camera_control(vfx_type, vfx_params)
if cam:
log.info(" 🎥 运镜控制: %s", cam)
if sound == "on":
log.info(" 🔊 原生有声模式(sound=on)—— 提示词里的对话将被念出来")
if image_path and Path(image_path).exists():
tid = self.submit_image2video(image_path, prompt, model=model, duration=duration,
camera_control=cam, tail_image_path=tail_image_path,
sound=sound)
kind = "image2video"
log.info(" 🎬 可灵图生视频任务: %s", tid)
else:
tid = self.submit_text2video(prompt, model=model, duration=duration,
aspect_ratio=aspect_ratio, camera_control=cam,
sound=sound)
kind = "text2video"
log.info(" 🎬 可灵文生视频任务: %s", tid)
# ★ 记录 task_id(供后续「视频生音效」—— video-to-audio 只认可灵 video_id)
self.save_task_id(dest, tid)
url = self.wait_for_video(tid, kind=kind, timeout=timeout, progress_cb=progress_cb)
return self.download(url, dest)
# ── 特效 → 运镜控制映射 ──────────────────────────
@staticmethod
def build_camera_control(vfx_type: str,
vfx_params: Optional[dict] = None) -> Optional[dict]:
"""把「特效类型」映射成可灵 `camera_control` 运镜参数
可灵规范:
type = simple(配合 config 六选一)| down_back | forward_up
| right_turn_forward | left_turn_forward
config(type=simple 时):horizontal / vertical / pan / tilt / roll / zoom
取值 [-10, 10],**只能填一个且不能为 0**
映射:
orbit 环绕运镜 → horizontal 6
bullet_time 子弹时间 → roll 5(配合提示词升格 + 后处理变速)
slow_motion 慢动作 → vertical 3(缓慢推进)
focus_lock 焦点锁定 → zoom 4(变焦)
down_back / forward_up / right_turn_forward / left_turn_forward(官方整段运镜预设)
"""
vt = (vfx_type or "").strip().lower()
if not vt:
return None
presets: dict = {
"orbit": {"type": "simple", "config": {"horizontal": 6}},
"bullet_time": {"type": "simple", "config": {"roll": 5}},
"slow_motion": {"type": "simple", "config": {"vertical": 3}},
"focus_lock": {"type": "simple", "config": {"zoom": 4}},
"down_back": {"type": "down_back", "config": {}},
"forward_up": {"type": "forward_up", "config": {}},
"right_turn_forward": {"type": "right_turn_forward", "config": {}},
"left_turn_forward": {"type": "left_turn_forward", "config": {}},
}
if vt in presets:
return presets[vt]
if vfx_params: # 自定义运镜(直接给 config)
return {"type": "simple", "config": vfx_params}
return None
@@ -0,0 +1,231 @@
#!/usr/bin/env python3
"""
capability_doctor.py — 能力体检(一条命令验证「清单说的 ready 是不是真能跑」)
背景:capabilities.yaml 是能力唯一真相源,但清单可能与实际脱节
(实测曾发现:frame_chain 代码早已实现却报 missing;
lipsync 实际用百炼却登记成 fal)。
本工具做静态体检,把「名不副实」的能力揪出来。
用法:
python capability_doctor.py # 全部能力体检
python capability_doctor.py --capability lipsync
python capability_doctor.py --json # 机器可读(供 Dashboard)
python capability_doctor.py --only-issues # 只看有问题的
检查项(全部零成本,不调用任何付费接口):
1. impl 文件是否存在(清单 impl 字段指向的代码)
2. provider 的 api_key 是否已配置(api_key_ref → config.yaml + local.yaml)
3. 状态一致性:status=ready 但没有任何可用 provider → ✗ 名不副实
4. 结构完整性:是否声明了 providers / status
退出码:0 = 无问题;1 = 存在 ⚠️ / ✗
"""
import argparse
import json
import re
import sys
from pathlib import Path
# ── 定位 ──
_SCRIPTS = Path(__file__).resolve().parent
_AVE = _SCRIPTS.parent # 05_tools/09_ave
if str(_SCRIPTS) not in sys.path:
sys.path.insert(0, str(_SCRIPTS))
try:
import yaml
except ImportError:
print("需要 pyyaml:pip install pyyaml")
sys.exit(2)
CAP_FILE = _AVE / "config" / "capabilities.yaml"
PLACEHOLDERS = {"", "sk-xxx", "xxx", "your_key", "your_openai_api_key", "omlx", "changeme", "todo"}
def load_caps() -> list:
d = yaml.safe_load(CAP_FILE.read_text(encoding="utf-8")) or {}
return d.get("capabilities") or []
def load_config_merged() -> dict:
"""合并 config.yaml(模板)与 local.yaml(本机真实密钥,优先级更高)"""
merged: dict = {}
def _merge(src: dict):
for k, v in (src or {}).items():
if isinstance(v, dict) and isinstance(merged.get(k), dict):
merged[k].update(v)
else:
merged[k] = v
tmpl = _AVE / "config.yaml"
if tmpl.exists():
try:
_merge(yaml.safe_load(tmpl.read_text(encoding="utf-8")) or {})
except Exception:
pass
local = Path.home() / "workbuddy-agent-os/agent-local/tools/ave/config/local.yaml"
if local.exists():
try:
_merge(yaml.safe_load(local.read_text(encoding="utf-8")) or {})
except Exception:
pass
return merged
def _get_by_path(cfg: dict, dotted: str):
cur = cfg
for part in (dotted or "").split("."):
if not isinstance(cur, dict) or part not in cur:
return None
cur = cur[part]
return cur
def _key_ok(val) -> bool:
if val is None:
return False
s = str(val).strip()
return bool(s) and s.lower() not in PLACEHOLDERS and not s.startswith("${")
def _impl_targets(impl: str) -> list:
"""从 impl 描述提取可核对的路径(文件或目录都可)
· 'composer/lipsync.py(lipsync_videoretalk)' → ['composer/lipsync.py']
· 'music_selector/' → ['music_selector']
· 'assets_manager/ + character_generator/' → ['assets_manager', 'character_generator']
"""
if not impl:
return []
files = re.findall(r"([\w./-]+\.py)", impl)
if files:
return files
dirs = re.findall(r"([\w][\w./-]*)/", impl)
seen, out = set(), []
for d in dirs:
d = d.rstrip('/')
if d and d not in seen:
seen.add(d)
out.append(d)
return out or [impl.strip()]
def check_capability(cap: dict, cfg: dict) -> dict:
"""单个能力的静态体检结果"""
cid = cap.get("id", "?")
status = (cap.get("status") or "").strip() or "(未声明)"
providers = cap.get("providers") or []
issues: list[str] = []
warns: list[str] = []
# ① impl 实现(文件或目录)
impl = cap.get("impl") or ""
targets = _impl_targets(impl)
impl_ok = None
if targets:
found = []
missing = []
for rel in targets:
cands = [_SCRIPTS / rel, _SCRIPTS / "capabilities" / rel, _AVE / rel]
(found if any(c.exists() for c in cands) else missing).append(rel)
impl_ok = bool(found)
# planned / missing 能力允许尚无实现;ready/partial 缺实现才是问题
if missing and status in ("ready", "partial"):
warns.append(f"impl 未找到: {', '.join(missing[:3])}")
elif status in ("ready", "partial"):
warns.append("未声明 impl(无法核对实现)")
# ② provider 的 key 配置情况
prov_ok: list[str] = []
prov_missing: list[str] = []
for p in providers:
ref = p.get("api_key_ref") or ""
pid = p.get("id") or "?"
if not ref:
prov_ok.append(f"{pid}(无需key)") # 本地实现(ffmpeg 等)
continue
val = _get_by_path(cfg, ref)
if _key_ok(val):
prov_ok.append(pid)
else:
prov_missing.append(f"{pid}({ref})")
# ③ 状态一致性
if status == "ready":
if providers and not prov_ok:
issues.append(f"status=ready 但没有任何可用 provider(缺: {', '.join(prov_missing)})")
if impl_ok is False:
issues.append("status=ready 但 impl 文件不存在")
elif status == "partial":
if providers and not prov_ok:
warns.append(f"partial 且全部 provider 不可用(缺: {', '.join(prov_missing)})")
elif status in ("planned", "missing", ""):
pass
# ④ 结构完整性
if not providers:
warns.append("未声明 providers")
verdict = "✗" if issues else ("⚠️" if warns else "✓")
return {
"id": cid,
"name": cap.get("name", ""),
"status": status,
"impl": impl,
"impl_ok": impl_ok,
"providers_ok": prov_ok,
"providers_missing": prov_missing,
"issues": issues,
"warns": warns,
"verdict": verdict,
}
def main():
ap = argparse.ArgumentParser(description="能力体检(静态,零成本)")
ap.add_argument("--capability", help="只查某个能力 id")
ap.add_argument("--json", action="store_true", help="输出 JSON")
ap.add_argument("--only-issues", action="store_true", help="只显示 ⚠️/✗")
args = ap.parse_args()
caps = load_caps()
cfg = load_config_merged()
if args.capability:
caps = [c for c in caps if c.get("id") == args.capability]
if not caps:
print(f"未找到能力: {args.capability}")
return 2
results = [check_capability(c, cfg) for c in caps]
if args.only_issues:
results = [r for r in results if r["verdict"] != "✓"]
if args.json:
print(json.dumps({"total": len(results), "results": results}, ensure_ascii=False, indent=1))
else:
bad = [r for r in results if r["verdict"] == "✗"]
warn = [r for r in results if r["verdict"] == "⚠️"]
good = [r for r in results if r["verdict"] == "✓"]
print(f"\n能力体检(共 {len(results)} 项) ✓ {len(good)} · ⚠️ {len(warn)} · ✗ {len(bad)}\n")
for r in results:
if args.only_issues and r["verdict"] == "✓":
continue
line = f" {r['verdict']} {r['id']:26s} [{r['status']:8s}]"
if r["providers_ok"]:
line += f" ✓{','.join(r['providers_ok'])[:36]}"
if r["providers_missing"]:
line += f" ✗{','.join(r['providers_missing'])[:36]}"
print(line)
for m in r["issues"]:
print(f" ✗ {m}")
for m in r["warns"]:
print(f" · {m}")
print()
return 1 if any(r["verdict"] == "✗" for r in results) else 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,382 @@
"""
capability_matcher — 技术优选层(创作意图 → 技术方案 + 缺口报告)
职责:
1. 读分镜(Shot 列表,含 tech_requirements 技术需求声明)
2. 对照能力目录(capabilities.yaml)与可行性规则库(feasibility_rules.yaml)
3. 产出每个镜头的「技术方案」(用什么能力/引擎/参数)与「缺口清单」
4. 计算能力目录指纹(版本+内容 hash),供「能力更新后自动升级方案」使用
设计原则:
· 导演只管创作表达,不指定工具;本层负责把需求翻译成技术方案
· 缺口是显性产物:无能力满足的需求 → 缺口清单 → 驱动能力建设
· 能力目录更新 → 指纹变化 → 自动标记「方案可升级 / 缺口已解决」
用法:
from capability_matcher import match_storyboard, catalog_fingerprint, check_upgradable
plan = match_storyboard(storyboard_dict)
fp = catalog_fingerprint()
up = check_upgradable(old_plan)
"""
from __future__ import annotations
import hashlib
import sys
from datetime import datetime, timezone
from pathlib import Path
import yaml
_SCRIPTS = Path(__file__).resolve().parent
if str(_SCRIPTS) not in sys.path:
sys.path.insert(0, str(_SCRIPTS))
from lib.logger import get_logger # noqa: E402
log = get_logger("capability_matcher")
_AVE_DIR = _SCRIPTS.parent
_CAPABILITIES = _AVE_DIR / "config" / "capabilities.yaml"
_RULES = _AVE_DIR / "config" / "feasibility_rules.yaml"
# 生成能力 → 能力目录 id 映射(分镜 generation_capability / type → capability id)
_CAP_MAP = {
"text_to_video": "text_to_video",
"image_to_video": "image_to_video",
"vfx": "vfx_apply",
"comfyui": "comfyui_workflow",
"3d_character": "character_3d",
}
def _now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
# ════════════════════════════════════════════════════════════
# 目录加载与指纹
# ════════════════════════════════════════════════════════════
def load_capabilities() -> dict:
try:
return yaml.safe_load(_CAPABILITIES.read_text(encoding="utf-8")) or {}
except Exception as e:
log.warning("能力目录读取失败: %s", e)
return {}
def load_rules() -> dict:
try:
return yaml.safe_load(_RULES.read_text(encoding="utf-8")) or {}
except Exception as e:
log.warning("规则库读取失败: %s", e)
return {}
def catalog_fingerprint() -> dict:
"""能力目录 + 规则库的内容指纹(版本号 + hash,两者任一变化都能检测)"""
def _h(p: Path) -> str:
try:
return hashlib.sha256(p.read_bytes()).hexdigest()[:16]
except Exception:
return ""
caps = load_capabilities()
rules = load_rules()
return {
"caps_version": caps.get("version", ""),
"caps_updated": caps.get("updated", ""),
"caps_hash": _h(_CAPABILITIES),
"rules_version": rules.get("version", ""),
"rules_hash": _h(_RULES),
}
def _cap_index() -> dict:
"""能力 id → 能力定义"""
caps = load_capabilities().get("capabilities", []) or []
return {c.get("id"): c for c in caps if c.get("id")}
# ════════════════════════════════════════════════════════════
# 规则引擎
# ════════════════════════════════════════════════════════════
def _rule_hit(rule: dict, shot: dict) -> bool:
"""判断单条规则是否命中该镜头"""
tr = shot.get("tech_requirements") or {}
cont = (tr.get("continuity") or {})
tg = rule.get("trigger") or {}
t = tg.get("type", "")
if t == "shot_duration_over":
try:
return float(shot.get("duration") or 0) > float(tg.get("value") or 0)
except Exception:
return False
if t == "duration_and_motion":
try:
over = float(shot.get("duration") or 0) > float(tg.get("duration_over") or 0)
except Exception:
over = False
return over and (tr.get("motion_level") in (tg.get("motion_in") or []))
if t == "text_fit":
text = shot.get("text") or ""
if not text:
return False
try:
need = len(str(text).strip()) / float(tg.get("chars_per_sec") or 4.5)
return need > float(shot.get("duration") or 0) + 0.3 # 容差
except Exception:
return False
if t == "tech_flag":
return tr.get(tg.get("field", "")) == tg.get("value")
if t == "tech_continuity":
return cont.get(tg.get("field", "")) in (tg.get("values") or [])
if t == "shot_or_need":
if shot.get("type") == tg.get("shot_type"):
return True
kws = tg.get("need_keywords") or []
blob = " ".join([str(shot.get("visual_description") or ""), str(shot.get("action") or "")]
+ [str(n) for n in (tr.get("needs") or [])])
return any(k in blob for k in kws)
if t == "need_keywords":
blob = " ".join([str(shot.get("visual_description") or ""), str(shot.get("action") or "")]
+ [str(n) for n in (tr.get("needs") or [])])
return any(k in blob for k in (tg.get("keywords") or []))
if t == "manual_check":
return False # 人工核对项,不在自动匹配中命中(由前端提示)
return False
# ════════════════════════════════════════════════════════════
# 基础能力判定(镜头 → 用哪个生成能力)
# ════════════════════════════════════════════════════════════
def base_capability(shot: dict) -> str:
"""镜头的生成能力:显式指定 > 按类型推导
talking_head → image_to_video(绑角色)或 text_to_video
vfx → vfx_apply;comfyui → comfyui_workflow;3d_character → character_3d
其他 → image_to_video(有角色锚点)或 text_to_video
"""
explicit = (shot.get("generation_capability") or "").strip()
if explicit:
return _CAP_MAP.get(explicit, explicit)
t = shot.get("type") or "action"
if t in ("vfx",):
return "vfx_apply"
if t == "comfyui":
return "comfyui_workflow"
if t == "3d_character":
return "character_3d"
# 绑了角色(含多角色演员表 cast_slots)或口播 → 图生视频
_slots = [s for s in (shot.get("cast_slots") or []) if s]
if (shot.get("character_id") or "").strip() or _slots or t == "talking_head":
return "image_to_video"
return "text_to_video"
# ════════════════════════════════════════════════════════════
# 主入口:匹配
# ════════════════════════════════════════════════════════════
def match_storyboard(storyboard: dict | list, project_id: str = "") -> dict:
"""分镜 → 技术方案 + 缺口报告
参数 storyboard: Storyboard.to_dict() 或 Shot 列表
返回:
{
project_id, generated_at, catalog: {...指纹},
shots: [ {shot_id, capability_id, capability_name, provider, maturity,
plan_status, issues:[{rule_id,name,level,problem,action}], duration_slot } ],
gaps: [ {need_key, name, status, problem, action, shot_ids:[...]} ],
summary: {total, ok, downgraded, blocked, gap_count}
}
"""
shots = storyboard.get("shots", []) if isinstance(storyboard, dict) else (storyboard or [])
if isinstance(storyboard, dict) and not project_id:
project_id = storyboard.get("project_id", "")
caps = _cap_index()
rules = (load_rules().get("rules") or [])
needs_map = load_rules().get("capability_needs") or {}
shot_plans: list[dict] = []
gap_acc: dict[str, dict] = {}
for s in shots:
sid = s.get("shot_id", "")
cap_id = base_capability(s)
cap = caps.get(cap_id) or {}
maturity = cap.get("status", "unknown")
providers = cap.get("providers") or []
provider = ""
if providers and isinstance(providers[0], dict):
provider = providers[0].get("name", "")
# 跑规则
issues = []
for r in rules:
if r.get("level") == "info":
continue # info 不进问题列表(可另作提示)
if _rule_hit(r, s):
issues.append({
"rule_id": r.get("id"), "name": r.get("name"),
"level": r.get("level"), "nature": r.get("nature", ""),
"problem": r.get("problem", ""),
"action": r.get("action", ""),
})
nk = r.get("need")
if nk:
g = gap_acc.setdefault(nk, {
"need_key": nk,
"name": (needs_map.get(nk) or {}).get("name", nk),
"status": (needs_map.get(nk) or {}).get("status", "unknown"),
"note": (needs_map.get(nk) or {}).get("note", ""),
"problem": r.get("problem", ""),
"action": r.get("action", ""),
"shot_ids": [],
})
if sid and sid not in g["shot_ids"]:
g["shot_ids"].append(sid)
# 方案状态
if any(i["level"] == "blocking" for i in issues):
status = "blocked"
elif issues:
status = "downgraded"
elif maturity in ("planned", "missing"):
status = "blocked"
else:
status = "ok"
# 时长档位映射(生成能力档位:5s / 10s)
try:
dur = float(s.get("duration") or 0)
except Exception:
dur = 0.0
slot = 10 if dur > 5 else 5
if dur > 10:
slot = None # 超限,需切条/接续
shot_plans.append({
"shot_id": sid,
"capability_id": cap_id,
"capability_name": cap.get("name", cap_id),
"provider": provider,
"maturity": maturity,
"duration_slot": slot,
"plan_status": status,
"issues": issues,
"manual_override": bool(s.get("tech_plan_manual")),
})
gaps = sorted(gap_acc.values(), key=lambda g: g["need_key"])
summary = {
"total": len(shot_plans),
"ok": sum(1 for p in shot_plans if p["plan_status"] == "ok"),
"downgraded": sum(1 for p in shot_plans if p["plan_status"] == "downgraded"),
"blocked": sum(1 for p in shot_plans if p["plan_status"] == "blocked"),
"gap_count": len(gaps),
}
return {
"project_id": project_id,
"generated_at": _now(),
"catalog": catalog_fingerprint(),
"shots": shot_plans,
"gaps": gaps,
"summary": summary,
}
# ════════════════════════════════════════════════════════════
# 能力更新 → 自动升级检测
# ════════════════════════════════════════════════════════════
def check_upgradable(old_plan: dict) -> dict:
"""对比旧技术方案的能力目录指纹与当前指纹
返回:
{
outdated: bool,
reason: str,
resolved_gaps: [ {need_key, name, was_status, now_status} ], # 缺口已解决
affected_shots: [shot_id], # 需重算的镜头
summary: {resolved, still_open}
}
"""
if not old_plan:
return {"outdated": False, "reason": "无历史方案", "resolved_gaps": [],
"affected_shots": [], "summary": {"resolved": 0, "still_open": 0}}
old_fp = old_plan.get("catalog") or {}
now_fp = catalog_fingerprint()
changed = (old_fp.get("caps_hash") != now_fp.get("caps_hash")
or old_fp.get("rules_hash") != now_fp.get("rules_hash"))
needs_map = load_rules().get("capability_needs") or {}
resolved, still_open, affected = [], [], []
for g in (old_plan.get("gaps") or []):
nk = g.get("need_key")
was = g.get("status")
now = (needs_map.get(nk) or {}).get("status", "missing")
if was in ("missing", "planned") and now in ("ready", "partial"):
resolved.append({"need_key": nk, "name": g.get("name", nk),
"was_status": was, "now_status": now})
affected.extend(g.get("shot_ids") or [])
else:
still_open.append(nk)
if not changed:
reason = "能力目录未变化"
elif resolved:
reason = f"能力目录已更新:{len(resolved)} 项缺口已解决,可重新生成方案"
else:
reason = "能力目录已更新(无缺口变化)"
return {
"outdated": changed,
"reason": reason,
"resolved_gaps": resolved,
"affected_shots": sorted(set(affected)),
"summary": {"resolved": len(resolved), "still_open": len(still_open)},
}
# ── CLI 自测 ──
if __name__ == "__main__":
import json
fp = catalog_fingerprint()
print("目录指纹:", json.dumps(fp, ensure_ascii=False))
demo = {
"project_id": "demo",
"shots": [
{"shot_id": "shot_01", "duration": 4, "type": "talking_head",
"text": "你好,这是测试台词。", "character_id": "c1",
"tech_requirements": {"continuity": {"character": "must", "expression": "prefer"},
"chain_from_prev": False, "motion_level": "small",
"camera_difficulty": "fixed", "needs": []}},
{"shot_id": "shot_02", "duration": 8, "type": "action",
"tech_requirements": {"continuity": {"location": "must"}, "chain_from_prev": True,
"motion_level": "large", "camera_difficulty": "complex",
"needs": ["子弹时间"]}},
{"shot_id": "shot_03", "duration": 12, "type": "action", "text": "",
"tech_requirements": {"needs": ["手部特写"]}},
],
}
plan = match_storyboard(demo)
print("\n方案摘要:", json.dumps(plan["summary"], ensure_ascii=False))
for p in plan["shots"]:
print(f" {p['shot_id']}: {p['capability_name']} slot={p['duration_slot']} status={p['plan_status']} issues={[i['rule_id'] for i in p['issues']]}")
print("\n缺口:")
for g in plan["gaps"]:
print(f" [{g['status']}] {g['name']} ← {g['shot_ids']}")
@@ -0,0 +1,397 @@
"""
角色适配器 (Character Adapter) — 漫剧视频工厂
功能:
- 接收脚本 + 角色注册中心的角色信息
- 遍历脚本的每个 segment,注入角色描述块
- 生成适配后的分镜列表(含角色参考信息)
- 输出可用于 Gate 2 展示的数据
用法:
from character_adapter import CharacterAdapter
from character_registry import CharacterRegistry
registry = CharacterRegistry()
adapter = CharacterAdapter(registry)
adapted = adapter.adapt(script)
"""
import copy
import os
from typing import Optional
# ── 表情映射 ──
EMOTION_MAP = {
"中性": "neutral",
"微笑": "smile",
"开心": "happy",
"愤怒": "angry",
"悲伤": "sad",
"害怕": "fear",
"惊讶": "surprise",
"厌恶": "disgust",
"平静": "neutral",
"喜悦": "happy",
"激动": "excited",
"忧郁": "sad",
"冷漠": "neutral",
}
def _map_emotion(chinese_emotion: str) -> str:
"""将中文情绪映射为表情键名"""
return EMOTION_MAP.get(chinese_emotion, "neutral")
class CharacterAdapter:
"""角色适配器 — 将脚本中的主角替换为注册角色的描述"""
def __init__(self, registry=None):
"""
参数:
registry: CharacterRegistry 实例,None 则延迟初始化
"""
self._registry = registry
@property
def registry(self):
if self._registry is None:
from character_registry import CharacterRegistry
self._registry = CharacterRegistry()
return self._registry
# ── 核心方法 ──
def adapt(self, script: dict) -> dict:
"""
将脚本中的每个分镜适配为注册角色版本
参数:
script: 标准格式脚本字典
返回:
适配后的脚本(含 character_block 字段)
"""
char = self.registry.get_active_character()
adapted = copy.deepcopy(script)
meta = adapted.get("meta", {})
meta["adapted_character"] = char.name
meta["adapt_time"] = __import__("time").strftime("%Y-%m-%d %H:%M:%S")
for seg in adapted.get("segments", []):
self._adapt_segment(seg, char)
return adapted
def adapt_to_storyboard(self, script: dict) -> list[dict]:
"""
将脚本转换为适配后的分镜列表(展平,无 meta)
返回:
每个分镜包含角色描述块信息,供 Gate 2 展示和视觉生成使用
"""
char = self.registry.get_active_character()
result = []
for seg in script.get("segments", []):
adapted_seg = copy.deepcopy(seg)
self._adapt_segment(adapted_seg, char)
result.append(adapted_seg)
return result
def _adapt_segment(self, seg: dict, char) -> dict:
"""
适配单个分镜 — 注入角色描述块
对每个分镜:
1. 生成角色描述块 (character_block)
2. 在 scene/description 中确保角色名正确
3. 注入角色参考信息
"""
# 生成角色描述块
seg["character_block"] = char.build_prompt_block(
style=seg.get("visual_style", "manhua"),
expression=seg.get("emotion", "中性"),
scene=seg.get("scene", ""),
camera_angle=seg.get("camera", "中景"),
)
# 注入角色参考信息
seg["character_ref"] = char.to_storyboard_block()
# 注入参考图路径(用于 Kling character_refs)
ref_images = char.reference_images
if ref_images.get("portrait"):
seg["reference_image"] = ref_images["portrait"]
elif ref_images.get("grid"):
seg["reference_image"] = ref_images["grid"]
# 注入表情参考图(根据 segment 的情绪)
emotion = seg.get("emotion", "中性")
expressions = ref_images.get("expressions", {})
emotion_key = _map_emotion(emotion)
if emotion_key in expressions and expressions[emotion_key]:
seg["emotion_reference"] = expressions[emotion_key]
# 确保场景描述中出现角色名(如果还没出现)
if char.name not in seg.get("description", ""):
seg["description"] = f"{char.name}{seg.get('description', '')}"
return seg
# ── 批量适配 ──
def adapt_batch(self, scripts: list[dict]) -> list[dict]:
"""批量适配多个脚本"""
return [self.adapt(s) for s in scripts]
# ── 预览 ──
def preview_adaptation(self, script: dict) -> dict:
"""
预览适配效果(不修改原始脚本)
返回:
{
"character": "小漫",
"segments_before": [...], # 适配前的 scene 列表
"segments_after": [...] # 适配后的 scene+character_block
}
"""
char = self.registry.get_active_character()
before = []
after = []
for seg in script.get("segments", []):
before.append({
"id": seg.get("id"),
"scene": seg.get("scene"),
"description": seg.get("description"),
})
adapted = copy.deepcopy(seg)
self._adapt_segment(adapted, char)
after.append({
"id": adapted.get("id"),
"scene": adapted.get("scene"),
"description": adapted.get("description"),
"character_block": adapted.get("character_block", ""),
})
return {
"character": char.name,
"total_segments": len(script.get("segments", [])),
"before": before,
"after": after,
}
# ── Kling character_refs 构建 ──
def build_kling_character_refs(self, name: str = "") -> list[dict]:
"""
构建 Kling API 支持的 character_refs 格式
Kling 图生视频支持传入 character_refs 数组来保持角色一致性:
[{"image_url": "path/to/portrait.png", "image_type": "人物全身照"}]
返回:
[{"image_url": "...", "image_type": "人物全身照"}, ...]
"""
char = self.registry.get_character(name) if name else self.registry.get_active_character()
ref_images = char.reference_images
refs = []
# 优先级: portrait > grid > cells
portrait = ref_images.get("portrait", "")
grid = ref_images.get("grid", "")
# 如果 portrait 可用 → 直接使用
if portrait:
refs.append({
"image_url": _ensure_file_url(portrait),
"image_type": "人物全身照",
})
# 添加表情参考
expressions = ref_images.get("expressions", {})
for emotion, img_path in expressions.items():
if img_path:
refs.append({
"image_url": _ensure_file_url(img_path),
"image_type": f"表情_{emotion}",
})
elif grid:
refs.append({
"image_url": _ensure_file_url(grid),
"image_type": "人物全身照",
})
return refs
def inject_character_refs(self, segments: list[dict], char_name: str = "") -> list[dict]:
"""
将 character_refs 注入到每个 segment 中
参数:
segments: 分镜列表
char_name: 角色名(可选,默认当前活跃角色)
返回:
注入 character_refs 后的分镜列表
"""
char = self.registry.get_character(char_name) if char_name else self.registry.get_active_character()
refs = self.build_kling_character_refs(char_name or char.name)
result = []
for seg in segments:
adapted = copy.deepcopy(seg)
# 注入 character_refs(每个分镜独立一份,方便逐帧控制)
adapted["character_refs"] = refs
result.append(adapted)
return result
# ── Visual Storyboard 构建(Gate 1.5 用) ──
def build_visual_prompts(self, storyboard: list[dict]) -> list[dict]:
"""
从适配后的分镜生成视觉提示词(Gate 1.5 审核内容)
每个分镜包含完整视觉 prompt:
- character_block: 角色描述块
- scene: 场景描述
- visual_prompt: 完整视觉 prompt = 角色块 + 场景 + 镜头
- reference_image: 参考图路径
- emotion: 情绪
- duration_sec: 时长
返回:
视觉提示词列表
"""
char = self.registry.get_active_character()
char_block = char.build_prompt_block(style="manhua")
result = []
for seg in storyboard:
# 构建完整 visual prompt
scene_desc = seg.get("scene", "")
camera = seg.get("camera", "中景")
emotion = seg.get("emotion", "中性")
duration = seg.get("duration_sec", 5)
visual_prompt = (
f"{char_block}\n"
f"[场景] {scene_desc}\n"
f"[镜头] {camera}\n"
f"[表情] {emotion}"
)
entry = {
"id": seg.get("id", 0),
"scene": scene_desc,
"visual_prompt": visual_prompt,
"reference_image": seg.get("reference_image",
char.reference_images.get("portrait", "")),
"emotion": emotion,
"duration_sec": duration,
}
# 如果有关键词/台词
if seg.get("narration") or seg.get("dialogue"):
entry["narration"] = seg.get("narration", "")
entry["dialogue"] = seg.get("dialogue", "")
result.append(entry)
return result
# ── 工具函数 ──
def _ensure_file_url(path: str) -> str:
"""确保路径以 file:// 开头(Kling API 需要 URL)"""
if not path:
return ""
if path.startswith("http://") or path.startswith("https://") or path.startswith("file://"):
return path
return f"file://{os.path.abspath(path)}"
# ═══════════════════════════════════════════════════════════
# CLI
# ═══════════════════════════════════════════════════════════
def cli():
import argparse
parser = argparse.ArgumentParser(description="角色适配器")
parser.add_argument("action", nargs="?", default="preview",
choices=["adapt", "preview", "batch", "refs", "visual-prompts"])
parser.add_argument("--script", default="", help="脚本 YAML 文件路径")
parser.add_argument("--character", default="", help="指定角色(可选)")
parser.add_argument("--output", default="", help="输出路径")
args = parser.parse_args()
from character_registry import CharacterRegistry
from script_schemas import load_script
registry = CharacterRegistry()
adapter = CharacterAdapter(registry)
# 如果有指定角色,切换过去
if args.character:
registry.switch_to(args.character)
print(f"角色适配器: 当前角色 = {registry.get_active_name()}")
print()
if args.action in ("adapt", "preview"):
if not args.script:
print("❌ 请指定 --script")
return
script = load_script(args.script)
if args.action == "preview":
preview = adapter.preview_adaptation(script)
print(f"预览适配 ({preview['total_segments']} 个分镜):")
print(f"角色: {preview['character']}")
for b, a in zip(preview["before"], preview["after"]):
print(f"\n #{b['id']} {b['scene']}")
print(f" 适配前: {b['description'][:50]}...")
print(f" 适配后: {a['description'][:50]}...")
print(f" 角色块: {a['character_block'][:80]}...")
else:
adapted = adapter.adapt(script)
if args.output:
import yaml
with open(args.output, "w", encoding="utf-8") as f:
yaml.dump({"script": adapted}, f, allow_unicode=True,
default_flow_style=False, sort_keys=False)
print(f"✅ 已保存适配后脚本: {args.output}")
else:
print(json.dumps(adapted, indent=2, ensure_ascii=False))
elif args.action == "refs":
refs = adapter.build_kling_character_refs(args.character)
print(f"Kling character_refs for {args.character or registry.get_active_name()}:")
print(json.dumps(refs, indent=2, ensure_ascii=False))
elif args.action == "visual-prompts":
if not args.script:
print("❌ 请指定 --script")
return
script = load_script(args.script)
storyboard = adapter.adapt_to_storyboard(script)
prompts = adapter.build_visual_prompts(storyboard)
print(f"Visual prompts (Gate 1.5):")
print(json.dumps(prompts, indent=2, ensure_ascii=False))
if __name__ == "__main__":
import json
cli()
@@ -0,0 +1,11 @@
"""
角色生成器包 — Character Generator Package
提供从粗方向到完整角色变体素材的全流程:
- DirectionExpander 方向扩展(粗方向→详细描述)
- AttributeExtractor 属性提取(自然语言→结构化属性)
- PromptAssembler 提示词组装(结构化属性→Kling/即梦提示词)
- VariantGenerator 变体生成(Kling API 批量生成多角度/表情)
- AssetRegistrar 资产注册(注册到 registry.yaml + 知识库)
- CharacterGenerationPipeline 全流程编排器
"""
@@ -0,0 +1,304 @@
"""
素材注册器 (AssetRegistrar) — 将生成的角色变体素材注册到 registry.yaml + 知识库
注册内容:
- 更新 registry.yaml 中的 reference_images (face_anchor, body各角度, expressions)
- 更新 outfit_presets(如果有自定义服装变体)
- 生成标签元数据供 asset_manager 索引
- 回写到 prompts/character_sheet_prompts.md(知识库)
"""
import json
import os
import sys
import yaml
from pathlib import Path
from datetime import datetime
from typing import Optional
path = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, path)
from lib.logger import get_logger
logger = get_logger('asset_registrar')
# ── 路径 ──
SCRIPTS_DIR = Path(__file__).resolve().parent
REGISTRY_PATH = SCRIPTS_DIR.parent / 'character_registry' / 'registry.yaml'
REGISTRY_BACKUP_DIR = SCRIPTS_DIR.parent / 'character_registry' / 'backups'
PROMPTS_DIR = SCRIPTS_DIR / 'prompts'
CHARACTER_SHEET_PROMPTS = PROMPTS_DIR / 'character_sheet_prompts.md'
# ── 变体标签映射 ──
VARIANT_TAGS = {
'front_face': ('面部特写', '基准', '正面'),
'multi_angle': {
'default': ('全身', '正面', '体型'),
'front': ('全身', '正面', '体型'),
'side': ('全身', '侧面', '体型'),
'right_three_quarter': ('全身', '右侧45°', '体型'),
'right_side': ('全身', '纯右侧', '体型'),
'back': ('全身', '背面', '体型'),
},
'expression': {
'default': ('表情', '中性'),
'neutral': ('表情', '中性'),
'smile': ('表情', '微笑'),
'focused': ('表情', '专注'),
'laugh': ('表情', '大笑'),
'serious': ('表情', '严肃'),
},
'scene': {
'morning_run': ('场景', '晨跑', '运动'),
'gym_workout': ('场景', '健身房', '力量训练'),
'street_basketball': ('场景', '篮球', '街头'),
'office_work': ('场景', '办公室', '工作'),
'outdoor_fitness': ('场景', '户外', '健身'),
},
'sport_action': {
'running': ('运动', '奔跑', '动态'),
'jumping': ('运动', '跳跃', '动态'),
'defensive': ('运动', '防守', '姿态'),
'stretching': ('运动', '拉伸', '热身'),
},
}
class AssetRegistrar:
"""将生成的角色素材注册到 registry.yaml + 知识库。"""
def __init__(self):
self._registry_data = self._load_registry()
self._registry_path = REGISTRY_PATH
# ══════════════════════════════════════════════════
# 底层 IO
# ══════════════════════════════════════════════════
def _load_registry(self) -> dict:
"""从 registry.yaml 加载数据。"""
if not REGISTRY_PATH.exists():
return {'active_character': None, 'characters': {}}
with open(REGISTRY_PATH, 'r', encoding='utf-8') as f:
return yaml.safe_load(f) or {'active_character': None, 'characters': {}}
def _save_registry(self):
"""保存 registry.yaml(先备份再写入)
⚠️ registry.yaml 已**废弃**(账号/角色归属以 ORACLE.yaml 为准,见 CONSTITUTION 规则 3),
默认**不再写入**以免污染仓库;如需临时启用,设置环境变量 `AVE_WRITE_REGISTRY=1`。
"""
import os
if os.environ.get("AVE_WRITE_REGISTRY") != "1":
print(" ⚠️ registry.yaml 已废弃,跳过写入(如需启用设 AVE_WRITE_REGISTRY=1)")
return
REGISTRY_BACKUP_DIR.mkdir(parents=True, exist_ok=True)
if REGISTRY_PATH.exists():
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
backup_path = REGISTRY_BACKUP_DIR / f'registry_{timestamp}.yaml'
with open(backup_path, 'w', encoding='utf-8') as f:
f.write(REGISTRY_PATH.read_text(encoding='utf-8'))
with open(REGISTRY_PATH, 'w', encoding='utf-8') as f:
yaml.dump(
self._registry_data,
f,
allow_unicode=True,
default_flow_style=False,
sort_keys=False,
)
# ══════════════════════════════════════════════════
# 注册角色属性
# ══════════════════════════════════════════════════
def register_character_properties(self, name: str, attrs: dict) -> bool:
"""注册/更新角色的属性信息到 registry.yaml。
Args:
name: 角色名
attrs: 结构化属性字典
Returns:
是否成功
"""
chars = self._registry_data['characters']
if name not in chars:
chars[name] = {}
char = chars[name]
# 写入永久属性
for key in ('name', 'description', 'base', 'personality', 'voice_style', 'art_style'):
if key in attrs and attrs[key]:
char[key] = attrs[key]
# 写入分层特征
for category in ('appearance', 'face', 'body', 'clothing', 'accessories'):
if category in attrs and attrs[category]:
char[category] = attrs[category]
self._save_registry()
logger.info(f' 已注册角色属性: {name}')
return True
# ══════════════════════════════════════════════════
# 注册参考图
# ══════════════════════════════════════════════════
def register_reference_images(
self,
name: str,
ref_type: str,
filepath: str,
option: str = '',
) -> bool:
"""注册单张参考图到 registry。
Args:
name: 角色名
ref_type: 类型 (face_anchor/baseline/body/expression/real_photo)
filepath: 图片路径
option: 选项(如 body 的 front/side, expression 的 smile/neutral)
Returns:
是否成功
"""
chars = self._registry_data['characters']
if name not in chars:
chars[name] = {}
char = chars[name]
if 'reference_images' not in char:
char['reference_images'] = {}
refs = char['reference_images']
if ref_type not in refs:
refs[ref_type] = []
entry = {'path': filepath}
if option:
entry['option'] = option
refs[ref_type].append(entry)
self._save_registry()
return True
# ══════════════════════════════════════════════════
# 批量注册定妆照
# ══════════════════════════════════════════════════
def register_portrait_set(
self,
name: str,
attrs: dict,
baseline: Optional[str] = None,
angles: Optional[dict] = None,
expressions: Optional[dict] = None,
) -> bool:
"""批量注册一套完整的定妆照。
Args:
name: 角色名
attrs: 结构化属性
baseline: 基准照路径
angles: {angle_name: filepath, ...}
expressions: {expr_name: filepath, ...}
Returns:
是否成功
"""
if baseline:
self.register_reference_images(name, 'face_anchor', baseline)
if angles:
for angle, path in angles.items():
self.register_reference_images(name, 'body', path, option=angle)
if expressions:
for expr, path in expressions.items():
self.register_reference_images(name, 'expression', path, option=expr)
return True
# ══════════════════════════════════════════════════
# 更新知识库
# ══════════════════════════════════════════════════
def update_knowledge_base(self, name: str, attrs: dict) -> bool:
"""将角色描述更新到 character_sheet_prompts.md。
Args:
name: 角色名
attrs: 结构化属性
Returns:
是否成功
"""
base = attrs.get('base', '')
face = attrs.get('face', {})
body = attrs.get('body', {})
# 构建角色描述块
char_block_parts = [f'### {name}']
char_block_parts.append(f'- **描述**: {attrs.get("description", "")}')
char_block_parts.append(
f'- **面部**: 脸型={face.get("shape", "?")}, '
f'眼睛={face.get("eyes", "?")}, '
f'发型={face.get("hair", "?")}, '
f'肤色={face.get("skin", "?")}'
)
char_block_parts.append(
f'- **体型**: 体态={body.get("build", "?")}, '
f'身高={body.get("height", "?")}'
)
char_block_parts.append(
f'- **性格**: {attrs.get("personality", "?")}'
)
char_block_parts.append(
f'- **语音**: {attrs.get("voice_style", "?")}'
)
char_block = '\n'.join(char_block_parts)
new_section = f'\n\n{char_block} # 自动注册)\n\n'
# 写入 prompts/character_sheet_prompts.md
PROMPTS_DIR.mkdir(parents=True, exist_ok=True)
if CHARACTER_SHEET_PROMPTS.exists():
existing = CHARACTER_SHEET_PROMPTS.read_text(encoding='utf-8')
if f'### {name}' not in existing:
with open(CHARACTER_SHEET_PROMPTS, 'a', encoding='utf-8') as f:
f.write(new_section)
else:
with open(CHARACTER_SHEET_PROMPTS, 'w', encoding='utf-8') as f:
f.write(f'# 角色定妆照提示词库\n{new_section}')
logger.info(f' 已更新知识库: {name}')
return True
# ══════════════════════════════════════════════════
# 标签工具
# ══════════════════════════════════════════════════
def get_variant_tags(self, variant_type: str, option: str = '') -> tuple:
"""获取变体类型的标签元数据。
Args:
variant_type: 变体类型
option: 选项名
Returns:
标签元组
"""
tags = VARIANT_TAGS.get(variant_type, {})
if isinstance(tags, tuple):
return tags
if isinstance(tags, dict):
return tags.get(option, tags.get('default', ()))
return ()
if __name__ == '__main__':
registrar = AssetRegistrar()
print(f'素材注册器就绪, registry: {REGISTRY_PATH}')
print(f'知识库: {CHARACTER_SHEET_PROMPTS}')
@@ -0,0 +1,276 @@
"""
属性提取器 (AttributeExtractor) — 从自然语言角色描述中提取结构化属性
输出结构:
{
"name": "角色名",
"description": "完整自然语言描述",
"base": "核心特征(不含服装的永久特征)",
"appearance": ["特征1", "特征2", ...], # 外观特征列表
"face": {"shape": "国字脸", "eyes": "单眼皮", ...},
"body": {"build": "健壮", "height": "175cm", ...},
"clothing": {"top": "深灰夹克", "inner": "白色内衬", ...},
"accessories": ["蓝牙耳机"],
"personality": "沉稳内敛",
"voice_style": "成熟稳重男声",
"art_style": "写实摄影风格",
}
"""
import json
import re
from typing import Optional
# ── 语音风格枚举 ──
VOICE_STYLES = (
'活泼少女音',
'温柔青年音',
'成熟稳重男声',
'清澈少年音',
'低沉磁性',
'甜美少女音',
'知性女声',
'治愈系',
'默认',
)
# ── 画风枚举 ──
ART_STYLES = (
'写实摄影风格',
'日系漫画风格',
'电影级摄影风格',
'超写实风格',
'赛博朋克风格',
'水墨风格',
)
class AttributeExtractor:
"""从自然语言描述中提取结构化角色属性。"""
# ══════════════════════════════════════════════════
# 主入口
# ══════════════════════════════════════════════════
def extract(self, description: str, name: str = '') -> dict:
"""从自然语言描述中提取结构化属性。
Args:
description: 自然语言角色描述
name: 角色名称
Returns:
结构化属性字典
"""
if not description:
return self._default_attributes(name)
attrs = self._default_attributes(name)
attrs['description'] = description
attrs['base'] = self._extract_base(description)
attrs['appearance'] = self._extract_appearance_list(description)
attrs['face'] = self._extract_face(description)
attrs['body'] = self._extract_body(description)
attrs['clothing'] = self._extract_clothing(description)
attrs['accessories'] = self._extract_accessories(description)
attrs['personality'] = self._extract_personality(description)
attrs['voice_style'] = self._extract_voice(description)
attrs['art_style'] = self._extract_art_style(description)
return attrs
# ══════════════════════════════════════════════════
# 默认值
# ══════════════════════════════════════════════════
def _default_attributes(self, name: str = '') -> dict:
"""返回默认空属性模板。"""
return {
'name': name or '未命名',
'description': '',
'base': '',
'appearance': [],
'face': {k: '' for k in ('shape', 'eyes', 'nose', 'lips', 'hair', 'skin', 'facial_hair', 'glasses', 'distinctive')},
'body': {k: '' for k in ('build', 'height', 'posture', 'age_group')},
'clothing': {k: '' for k in ('top', 'inner', 'bottom', 'shoes', 'style')},
'accessories': [],
'personality': '默认',
'voice_style': '写实摄影风格',
}
# ══════════════════════════════════════════════════
# 各类别提取
# ══════════════════════════════════════════════════
def _extract_base(self, desc: str) -> str:
"""提取核心特征(去服装、去场景的永久性特征)。"""
base = re.sub(r'身穿[^,。]*', '', desc)
base = re.sub(r'穿着[^,。]*', '', base)
base = re.sub(r'([^)]*)', '', base)
base = base.replace(',', '').replace('。', '').replace(' ', '')
return base.strip()
def _extract_appearance_list(self, desc: str) -> list:
"""提取外观特征列表。
按分类匹配模式,返回 ["脸型:国字脸", "眼部:单眼皮", ...]。
"""
patterns = [
('(国字脸|圆脸|方脸|瓜子脸|长脸)', '脸型'),
('(单眼皮|双眼皮|内双|大眼睛|小眼睛|细长眼)', '眼部'),
('(黑色短发|黑短发|棕色短发|寸头|齐刘海|中长发|三七分|背头|卷发)', '发型'),
('(健壮|偏瘦|壮硕|匀称|标准|苗条|丰满|粗犷)', '体型'),
('(深灰[色]?[^,。]*夹克|浅灰[色]?[^,。]*卫衣|运动[^,。]*|连帽[^,。]*)', '服装'),
]
features = []
for pattern, category in patterns:
m = re.search(pattern, desc)
if m:
matched = m.group(1) if m.lastindex and m.lastindex >= 1 else m.group(0)
feature = f'{category}:{matched}'
if len(feature) <= 8:
features.append(feature)
return features
def _extract_face(self, desc: str) -> dict:
"""提取面部详细特征。"""
face = {k: '' for k in ('shape', 'eyes', 'nose', 'lips', 'hair', 'skin', 'facial_hair', 'glasses', 'distinctive')}
m = re.search(r'(国字脸|圆脸|方脸|瓜子脸|长脸|菱形脸)', desc)
if m:
face['shape'] = m.group(1)
m = re.search(r'(单眼皮|双眼皮|内双|大眼睛|小眼睛|细长眼|眼尾[^,。]*)', desc)
if m:
face['eyes'] = m.group(1)
m = re.search(r'(鼻梁[^,。]*?|鼻翼[^,。]*?|[^,。]*鼻[^,。]*)', desc)
if m:
face['nose'] = m.group(1)
m = re.search(r'(嘴唇[^,。]*?|嘴角[^,。]*?)', desc)
if m:
face['lips'] = m.group(1)
m = re.search(r'(黑[色]?[^,。]*发|棕[色]?[^,。]*发|寸头|平头|光头|卷发|直发|[^,。]*短发[^,。]*)', desc)
if m:
face['hair'] = m.group(1)
m = re.search(r'(小麦色|白皙|健康[^,。]*|古铜色|偏[^,。]*[肤皮])', desc)
if m:
face['skin'] = m.group(1)
m = re.search(r'(胡须|胡子|络腮胡|山羊胡|胡茬)', desc)
if m:
face['facial_hair'] = m.group(1)
m = re.search(r'([^,。]*眼镜[^,。]*)', desc)
if m:
face['glasses'] = m.group(1)
m = re.search(r'(耳[机塞][^,。]*|耳机|特殊特征[^,。]*)', desc)
if m:
face['distinctive'] = m.group(1)
return face
def _extract_body(self, desc: str) -> dict:
"""提取体型特征。"""
body = {k: '' for k in ('build', 'height', 'posture', 'age_group')}
m = re.search(r'(健壮|偏瘦|壮硕|匀称|标准|苗条|丰满|粗犷|肌肉[^,。]*)', desc)
if m:
body['build'] = m.group(1)
m = re.search(r'(\d+cm|\d+m\d+)', desc)
if m:
body['height'] = m.group(1)
m = re.search(r'(\d+岁)', desc)
if m:
body['age_group'] = m.group(1)
m = re.search(r'(挺拔|微驼|笔直|自然)', desc)
if m:
body['posture'] = m.group(1)
return body
def _extract_clothing(self, desc: str) -> dict:
"""提取服装特征。"""
clothing = {k: '' for k in ('top', 'inner', 'bottom', 'shoes', 'style')}
m = re.search(r'([^,。]*?[夹克卫衣衬衫T恤外套][^,。]*)', desc)
if m:
clothing['top'] = m.group(1)
m = re.search(r'([^,。]*?[内衬打底背心][^,。]*)', desc)
if m:
clothing['inner'] = m.group(1)
m = re.search(r'([^,。]*?[裤子短裤牛仔裤][^,。]*)', desc)
if m:
clothing['bottom'] = m.group(1)
m = re.search(r'(运动[^,。]*|商务[^,。]*|休闲[^,。]*|正式[^,。]*)', desc)
if m:
clothing['style'] = m.group(1)
return clothing
def _extract_accessories(self, desc: str) -> list:
"""提取配饰列表。"""
accessories = []
for m in re.finditer(r'(耳机|眼镜|手表|手环|项链|帽子|围巾|腰带|护腕|背包)', desc):
accessories.append(m.group(1))
return accessories
def _extract_personality(self, desc: str) -> str:
"""提取性格特征。"""
m = re.search(r'(性格[^,。]*|气质[^,。]*|性[^,。]*?)', desc)
if m:
text = m.group(1)
keywords = ('沉稳', '开朗', '温和', '内敛', '专注', '活泼', '坚韧', '倔强')
found = [k for k in keywords if k in text]
if found:
return '、'.join(found)
return '默认'
def _extract_voice(self, desc: str) -> str:
"""提取语音风格。"""
m = re.search(r'([^,。]*[音声][^,。]*)', desc)
if m:
voice_text = m.group(1)
# 关键词匹配
if '音' in voice_text or '声' in voice_text:
if '中年' in voice_text or '沉稳' in voice_text:
return '成熟稳重男声'
elif '少女' in voice_text or '活泼' in voice_text:
return '活泼少女音'
elif '青年' in voice_text or '温和' in voice_text:
return '温柔青年音'
return '默认'
def _extract_art_style(self, desc: str) -> str:
"""提取画风。"""
m = re.search(r'([^,。]*?[风格][^,。]*)', desc)
if m:
style_text = m.group(1)
if '风' in style_text or '格' in style_text:
return '写实摄影风格'
return '写实摄影风格'
if __name__ == '__main__':
extractor = AttributeExtractor()
test_desc = (
'一位约42岁的中国男性,体型健壮肩宽,黑色短发利落,'
'国字脸单眼皮眼尾微垂,鼻梁挺直,神态沉稳内敛,'
'肤色健康自然。身穿深灰色拉链运动夹克配白色速干内衬,'
'右耳戴着蓝色无线运动耳机。'
)
result = extractor.extract(test_desc, 'ghai_sports')
print(json.dumps(result, indent=2, ensure_ascii=False))
@@ -0,0 +1,132 @@
"""
方向扩展器 (DirectionExpander) — 将用户给的粗方向补充为完整自然语言描述
流程:
用户输入 → 调用本地 oMLX LLM (Qwen3.5) → 返回完整角色自然语言描述
扩展内容:
- 面部特征(五官、发型、脸型、肤色)
- 体型(身高、肩宽、体态)
- 服装(上装、下装、鞋子)
- 标志性配件/装饰
- 性格/气质
- 声音/语态
- 画风/风格倾向
如果用户描述已经足够详细,则直接使用,不做过度补充。
"""
import json
import os
import sys
from pathlib import Path
path = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, path)
from lib.config import load_config
from lib.logger import get_logger
logger = get_logger('direction_expander')
environ = os.environ.get
OMLX_BASE = environ('OMLX_BASE', 'http://localhost:8000/v1')
OMLX_MODEL = environ('OMLX_MODEL', 'Qwen3.5-4B-MLX-4bit')
SYSTEM_PROMPT = (
"你是一个专业的角色设计师助理。用户会给你一个角色的\"粗方向\"(一两句话的粗略描述),\n"
"你需要将它扩展为一段丰富、详细、可执行的自然语言角色描述。\n\n"
"扩展规则:\n"
"1. 保留用户原始描述中的所有信息,不得修改或扭曲\n"
"2. 补充合理细节:面部特征(五官形态、脸型、肤质)、发型发色、体型、身高体态、服装风格、标志性配件\n"
"3. 补充性格气质和声音特征\n"
"4. 如果用户给出了具体的细节关键词(如\"国字脸\"\"单眼皮\"\"蓝牙耳机\"),保留并丰富它们\n"
"5. 如果用户描述已经足够详细(超过80字),则基本照原样输出,仅微调润色\n"
"6. 描述要写实,不要说\"动漫风格\"——写实角色以真人摄影为参照\n"
"7. 输出**只有**角色描述本身,不要带任何前缀、标签或解释\n\n"
"示例输入:\n"
"\"一个运动型中年男性,喜欢户外跑步\"\n\n"
"示例输出:\n"
"\"一位约42岁的中国男性,体型健壮肩宽,黑色短发利落,国字脸单眼皮眼尾微垂,"
"鼻梁挺直,神态沉稳内敛,肤色健康自然偏小麦色。"
"身穿深灰色拉链运动夹克配白色速干内衬。"
"整体气质坚毅沉稳,给人可靠踏实的感觉。声音偏向成熟稳重的中低音。\""
)
class DirectionExpander:
"""将粗方向扩展为详细自然语言描述"""
def __init__(self):
self._base_url = OMLX_BASE
self._model = OMLX_MODEL
def expand(self, direction: str) -> str:
"""将粗方向扩展为详细自然语言描述。
Args:
direction: 用户输入的粗方向,如"运动型中年男性"
Returns:
扩展后的完整角色描述字符串
扩展策略:
- 空输入 → 返回空字符串
- 超过80字 → 已足够详细,直接使用(log info)
- 80字以内 → 调用本地 LLM 扩展(异常时回退到原始方向)
"""
if not direction or not direction.strip():
return ''
if len(direction) > 80:
logger.info('方向描述已足够详细(>80字),直接使用')
return direction.strip()
try:
return self._call_llm(direction)
except Exception as e:
logger.warning(f'LLM 扩展失败 ({e}),回退到直接使用原始方向')
return direction.strip()
def _call_llm(self, direction: str) -> str:
"""调用本地 oMLX LLM 扩展方向描述。
Args:
direction: 原始方向描述
Returns:
LLM 返回的角色详细描述,去除首尾空白
Raises:
网络/API/解析异常 — 由调用者处理
"""
import httpx
payload = {
'model': self._model,
'messages': [
{'role': 'system', 'content': SYSTEM_PROMPT},
{'role': 'user', 'content': direction},
],
'temperature': 0.7,
'max_tokens': 500,
}
resp = httpx.post(
f'{self._base_url}/chat/completions',
json=payload,
timeout=30,
)
resp.raise_for_status()
data = resp.json()
content = data.get('choices', [{}])[0].get('message', {}).get('content', '')
return content.strip()
if __name__ == '__main__':
expander = DirectionExpander()
test_inputs = [
'一个运动型中年男性',
'一位28岁的城市程序员,黑短发,戴细框金属眼镜',
]
for inp in test_inputs:
print(f'\n输入: {inp}')
print(f'输出: {expander.expand(inp)}')
@@ -0,0 +1,417 @@
"""
角色生成完整流水线 (CharacterGenerationPipeline)
完整流程:
用户方向 (粗方向)
↓
[DirectionExpander] — 调用本地 LLM 补充为完整自然语言描述
↓
详细角色描述
↓
[AttributeExtractor] — 提取结构化的面部/体型/服装/配件/性格
↓
结构化属性
↓
[PromptAssembler] — 按不同类型组装生成提示词
↓
生成提示词
↓
[VariantGenerator] — 调用 Kling API 生成各类变体
↓
变体图像
↓
[AssetRegistrar] — 写入 registry.yaml + 知识库
↓
完成
用法:
from character_generator.pipeline import CharacterGenerationPipeline
pipeline = CharacterGenerationPipeline()
# 完整流程:从粗方向开始
result = pipeline.run_full("一个运动型中年男性", character_name="new_char")
# 仅扩展描述(不生成图像)
desc = pipeline.expand_direction("一个运动型中年男性")
# 从已有描述开始(生成+注册)
result = pipeline.run_from_description("详细描述...", character_name="new_char")
# 仅生成定妆照网格
result = pipeline.generate_portrait("角色名", force=True)
"""
import json
import sys
import os
from pathlib import Path
from typing import Optional
path = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, path)
from character_generator.direction_expander import DirectionExpander
from character_generator.attribute_extractor import AttributeExtractor
from character_generator.prompt_assembler import PromptAssembler
from character_generator.variant_generator import VariantGenerator
from character_generator.asset_registrar import AssetRegistrar
from lib.logger import get_logger
logger = get_logger('character_pipeline')
class CharacterGenerationPipeline:
"""角色生成完整流水线
整合五个子模块,提供从粗方向到角色变体素材的一站式接口。
"""
def __init__(self):
self._expander = DirectionExpander()
self._extractor = AttributeExtractor()
self._assembler = PromptAssembler()
self._generator = VariantGenerator()
self._registrar = AssetRegistrar()
# ──────────────────────────────────────────────
# 主流程
# ──────────────────────────────────────────────
def run_full(
self,
direction: str,
character_name: str = '',
lang: str = 'zh',
generate_variants: bool = True,
seed: int = 0,
) -> dict:
"""完整流水线:从粗方向到注册完成。
Args:
direction: 用户输入的粗方向,如"一个运动型中年男性"
character_name: 角色名(为空则自动生成)
lang: 语言
generate_variants: 是否生成变体图像
seed: 固定种子(0=随机)
Returns:
{
"name": str,
"description": str,
"attributes": dict,
"baseline": dict or None,
"angles": dict or None,
"expressions": dict or None,
"grid": dict or None,
"registered": True,
}
"""
step_results = {}
# [1/5] 扩展方向
logger.info(f'[1/5] 扩展方向: {direction[:50]}...')
description = self._expander.expand(direction)
step_results['description'] = description
# 自动生成角色名
if not character_name:
character_name = self._generate_name(direction, description)
# [2/5] 提取属性
logger.info(f'[2/5] 提取属性: {character_name}')
attrs = self._extractor.extract(description, name=character_name)
step_results['attributes'] = attrs
# [3/5] 注册角色属性
logger.info('[3/5] 注册角色属性')
self._registrar.register_character_properties(character_name, attrs)
# [4/5] 生成变体
if generate_variants:
generated = self._generate_all(attrs, lang, seed)
step_results['generated'] = generated
else:
step_results['generated'] = {}
# 注册定妆照到 registry
generated = step_results.get('generated', {})
baseline = generated.get('baseline', {}).get('path', '')
angles = {
k: v['path']
for k, v in generated.get('angles', {}).items()
if v.get('path')
}
expressions = {
k: v['path']
for k, v in generated.get('expressions', {}).items()
if v.get('path')
}
if baseline or angles or expressions:
self._registrar.register_portrait_set(
character_name,
attrs,
baseline=baseline or None,
angles=angles or None,
expressions=expressions or None,
)
# [5/5] 更新知识库
logger.info('[4/5] 注册素材到知识库')
self._registrar.update_knowledge_base(character_name, attrs)
return {
'name': character_name,
'description': description,
'attributes': attrs,
'baseline': generated.get('baseline', {}),
'angles': generated.get('angles', {}),
'expressions': generated.get('expressions', {}),
'grid': generated.get('grid', {}),
'registered': True,
}
# ──────────────────────────────────────────────
# 从已有描述开始(跳过方向扩展)
# ──────────────────────────────────────────────
def run_from_description(
self,
description: str,
character_name: str = '',
lang: str = 'zh',
seed: int = 0,
) -> dict:
"""从完整描述直接开始,绕过方向扩展步骤。
Args:
description: 完整的角色自然语言描述
character_name: 角色名(为空则自动生成)
lang: 语言
seed: 固定种子
Returns:
同 run_full()
"""
if not character_name:
character_name = self._generate_name(description, description)
attrs = self._extractor.extract(description, name=character_name)
self._registrar.register_character_properties(character_name, attrs)
generated = self._generate_all(attrs, lang, seed)
# 注册定妆照到 registry
baseline = generated.get('baseline', {}).get('path', '')
angles = {
k: v.get('path', '')
for k, v in generated.get('angles', {}).items()
if v.get('path')
}
expressions = {
k: v.get('path', '')
for k, v in generated.get('expressions', {}).items()
if v.get('path')
}
self._registrar.register_portrait_set(
character_name,
attrs,
baseline=baseline or None,
angles=angles or None,
expressions=expressions or None,
)
self._registrar.update_knowledge_base(character_name, attrs)
return {
'name': character_name,
'description': description,
'attributes': attrs,
'baseline': generated.get('baseline', {}),
'angles': generated.get('angles', {}),
'expressions': generated.get('expressions', {}),
'grid': generated.get('grid', {}),
'registered': True,
}
# ──────────────────────────────────────────────
# 仅生成定妆照
# ──────────────────────────────────────────────
def generate_portrait(self, character_name: str, force: bool = False, seed: int = 0) -> dict:
"""仅基于已注册的角色信息生成定妆照网格。
需要角色已在 registry 中注册(有 attrs 属性)。
Args:
character_name: 角色名
force: 强制重新生成
seed: 固定种子
Returns:
变体生成结果 dict
"""
from character_registry import CharacterRegistry
registry = CharacterRegistry()
char = registry.get_character(character_name)
attrs = char.to_dict()
generated = self._generate_all(attrs, 'zh', seed, force)
# 注册定妆照
baseline = generated.get('baseline', {}).get('path', '')
if baseline:
self._registrar.register_reference_images(
character_name, 'face_anchor', baseline
)
for angle, path in generated.get('angles', {}).items():
self._registrar.register_reference_images(
character_name, 'body', path, option=angle
)
for expr, path in generated.get('expressions', {}).items():
self._registrar.register_reference_images(
character_name, 'expression', path, option=expr
)
return generated
# ──────────────────────────────────────────────
# 单步操作
# ──────────────────────────────────────────────
def expand_direction(self, direction: str) -> str:
"""仅扩展方向描述(不提取属性、不生成图像)。"""
return self._expander.expand(direction)
def extract_attributes(self, description: str, name: str = '') -> dict:
"""仅提取结构化属性(不生成图像、不注册)。"""
return self._extractor.extract(description, name=name)
# ──────────────────────────────────────────────
# 内部:批量生成变体
# ──────────────────────────────────────────────
def _generate_all(
self,
attrs: dict,
lang: str = 'zh',
seed: int = 0,
force: bool = False,
) -> dict:
"""调用 VariantGenerator 生成基准照 + 多角度 + 表情 + 网格。
每个步骤独立 try/except,失败时记录 warning 并继续。
保证不会因为单步失败而中断整个流程。
Args:
attrs: 结构化属性 dict
lang: 语言
seed: 固定种子
force: 强制重新生成
Returns:
{
"baseline": dict or {"error": str},
"angles": dict or {"error": str},
"expressions": dict or {"error": str},
"grid": dict or {"error": str},
}
"""
logger.info(f'[5/5] 生成变体 (seed={seed if seed else "随机"})')
result = {}
# 1/4: 正面基准肖像
try:
logger.info(' 1/4: 正面基准肖像...')
result['baseline'] = self._generator.generate_baseline_portrait(
attrs, lang, force, seed
)
except Exception as e:
logger.warning(f' ⚠ 基准照生成失败: {e}')
result['baseline'] = {'error': str(e)}
# 2/4: 多角度全身
try:
logger.info(' 2/4: 多角度全身...')
result['angles'] = self._generator.generate_all_angles(
attrs, lang, force, seed
)
except Exception as e:
logger.warning(f' ⚠ 多角度生成失败: {e}')
result['angles'] = {'error': str(e)}
# 3/4: 表情变体
try:
logger.info(' 3/4: 表情变体...')
result['expressions'] = self._generator.generate_all_expressions(
attrs, lang, force, seed
)
except Exception as e:
logger.warning(f' ⚠ 表情变体生成失败: {e}')
result['expressions'] = {'error': str(e)}
# 4/4: 网格定妆照
try:
mode = attrs.get('sheet_mode', 'standard')
logger.info(' 4/4: 网格定妆照...')
result['grid'] = self._generator.generate_grid_sheet(
attrs, mode, lang, force, seed
)
except Exception as e:
logger.warning(f' ⚠ 网格定妆照生成失败: {e}')
result['grid'] = {'error': str(e)}
return result
# ──────────────────────────────────────────────
# 内部:从方向/描述生成角色名
# ──────────────────────────────────────────────
def _generate_name(self, direction: str, description: str) -> str:
"""从方向/描述中提取角色名。
策略:
1. 在 direction 中查找 "XX岁的中/老/青年男性/女性" 模式
2. 若未命中,查找 "[角色名]男/女" 模式
3. 若仍未命中,取 direction 前 3 字
4. 追加当前时间戳(%H%M)避免重名
"""
import re
m = re.search(r'(\d+岁)?[的]?([\u4e00-\u9fff]{2,4}(?:男性|女性|男生|女生|少年|少女|中年|老人))', direction)
if not m:
m = re.search(r'([\u4e00-\u9fff]{2,6})[男女人]', direction)
if m:
base = m.group(1)
base = base.replace('的', '').replace('男性', '').replace('女性', '')
name = base if len(base) <= 3 else base[:-3]
else:
name = direction[:3]
from datetime import datetime
timestamp = datetime.now().strftime('%H%M')
return f'{name}_{timestamp}'
if __name__ == '__main__':
pipeline = CharacterGenerationPipeline()
print('\n=== 测试1: 方向扩展 ===')
direction = '一个运动型中年男性,喜欢户外跑步和健身'
desc = pipeline.expand_direction(direction)
print(f'方向: {direction}')
print(f'扩展: {desc}')
print('\n=== 测试2: 属性提取 ===')
attrs = pipeline.extract_attributes(desc, name='test_char')
print(f'外观: {attrs.get("appearance", "N/A")}')
print(f'面部: {attrs.get("face", "N/A")}')
print(f'服装: {attrs.get("clothing", "N/A")}')
print(f'配件: {attrs.get("accessories", "N/A")}')
print(f'性格: {attrs.get("personality", "N/A")}')
@@ -0,0 +1,370 @@
"""
character_generator/portrait_set.py — 短剧角色参考图**套装**(5 视角 · 半身)
## 为什么需要
短剧角色锁定需要「一套 5 张同人多视角」参考图。两种做法对比:
| | 一次生成(网格图) | **分开生成(本模块)** |
|---|---|---|
| 分辨率 | 每格仅整图的 1/6(≈416px,五官糊) | **每张全分辨率** ✓ |
| 一致性 | 格间互相干扰(串表情/服装) | **以主定妆为参考图 → 五官锁定** ✓ |
| 可控性 | 一格崩了整张重来 | 单张可重生成 ✓ |
| 成本 | 1 张 | 5 张(≈¥1) |
→ **短剧级角色卡必须分开生成**。
## 核心流程(关键技巧)
```
① 生成【正面半身主定妆】
② 用户确认满意
③ 以主定妆图作为**参考图**(I2I),逐张生成其余 4 个角度
④ 5 张全部入库为角色参考图
```
「把主定妆当参考图」是五官不崩的关键 —— 纯文生图跨角度时模型会重新"想象"脸。
## 5 个视角(源自短剧角色卡通用规范)
1. front_half 正面半身 · 主定妆(直视镜头)← 基准
2. side_45 45° 侧半身(脸微转向镜头)
3. profile 纯侧面轮廓(不看镜头)
4. face_closeup 面部特写(低头再抬眼看向镜头)
5. shoulder_look 回头斜侧半身(背部 45°,头回转)
"""
from pathlib import Path
# ── 视角定义(半身构图;短剧主要拍上半身,比全身更实用)──
# ── 画风层(写实 —— 源自 shuohao 实测:不写 photorealistic,会往 CG 带)──
LOOK_REALISM = (
"A real photograph, not a render. Full-frame camera, 85mm lens at f/5.6, "
"true-to-life white balance, unretouched, no beauty filter, slight film grain. "
"Soft directional key light with gentle fill; seamless cool-grey backdrop "
"with nothing else in frame."
)
# ── 短一致性清单(派生图用;**不要**把锚点的长描述整段重复进来)──
def _detect_pronoun(character_desc: str) -> tuple:
"""按角色描述自动判断英文人称(he/his · she/her · they/their)
⚠️ bug 教训(2026-10-04):构图指令原先把人称写死成 she/her,
用户生成「男医生」时 desc 正确、但英文指令里的 she 把模型带成了女性。
"""
d = str(character_desc or "")
male = any(k in d for k in ("男", "先生", "父亲", "爸爸", "哥哥", "弟弟", "叔叔", "大爷",
"male", " man", "boy", "gentleman"))
female = any(k in d for k in ("女", "小姐", "女士", "母亲", "妈妈", "姐姐", "妹妹", "阿姨",
"female", "woman", "girl", "lady"))
if male and not female:
return "he", "his", "him"
if female and not male:
return "she", "her", "her"
return "they", "their", "them"
_CONSISTENCY = ("Keep {his} exactly the same person as in the reference image: "
"same face, same hairstyle and hair color, same top garment.")
# ── 通用负面词(5 张共用;含防侧身 / 防多视图 / 压 AI 感)──
PORTRAIT_NEGATIVE_ZH = (
# 防误侧身(实测:不写几何又不禁这些,正面会画成微侧身 20~30°)
"three-quarter view, turned body, contrapposto, "
# 防多视图(character sheet / turnaround 会让模型在一张图里画多个视图)
"character sheet, multiple views, turnaround, model sheet, "
# 压 AI 感(写实)
"CGI, plastic skin, waxy skin, doll face, over-symmetry, HDR, "
"oversaturated, flat lighting, photorealistic, "
# 通用质量
"扭曲五官,变形,多手指,畸形,模糊,水印,文字,杂乱背景,多余物体,"
"夸张滤镜,崩坏,丑化,面部变色,肢体崩坏"
)
# ── 视角定义 ──
# ★ 关键规范(shuohao-skills 实测):
# 1. **锚点写完整角色描述;派生图只写「构图指令 + 短清单 + 画风层」**
# 2. **改图模型只听第一句** —— `shot` 必须是明确的构图改动指令
# 3. 正面必须写**几何**(肩正对 / 鼻子在中线 / 两耳可见),否则会侧身
# 4. 一致性清单**按视图取层**(大头照只写脸/头发/上装 —— 写下装会把镜头拉远)
PORTRAIT_VIEWS: dict = {
"front_half": {
"label": "正面半身(主定妆)",
"order": 1,
"is_anchor": True,
"shot": ("Half-body front view: {he} faces the camera squarely, shoulders square to the lens, "
"nose on the centerline of the face, both ears equally visible, "
"looking straight into the lens with a neutral expression."),
"consistency": "", # 锚点自带完整描述,无需清单
"default_bg": "seamless cool-grey studio backdrop",
"default_light": "soft even studio lighting",
"default_expr": "neutral expression",
},
"side_45": {
"label": "45° 侧半身",
"order": 2,
"is_anchor": False,
"shot": ("Turn {his} body 45 degrees toward the image-left: shoulders now angled away, "
"the far shoulder partly hidden behind the near one, about three-quarters of "
"{his} face still visible, eyes toward the lens."),
"consistency": _CONSISTENCY,
"default_bg": "seamless cool-grey studio backdrop",
"default_light": "soft even studio lighting",
"default_expr": "calm expression",
},
"profile": {
"label": "纯侧面轮廓",
"order": 3,
"is_anchor": False,
"shot": ("Full side profile: turn {his} body 90 degrees so {he} faces the right edge of the "
"image, only the right half of {his} face is visible, {he} does NOT look at the camera."),
"consistency": _CONSISTENCY,
"default_bg": "seamless cool-grey studio backdrop",
"default_light": "soft even studio lighting, gentle rim on the profile edge",
"default_expr": "quiet, eyes lowered slightly",
},
"face_closeup": {
"label": "面部特写",
"order": 4,
"is_anchor": False,
"shot": ("Zoom in to an extreme close-up head-and-shoulders portrait, passport-photo "
"framing: top of head near the top edge, chin at the vertical center, bottom "
"edge cutting at the collarbone. {He} tilts {his} head slightly down, then lifts "
"{his} eyes into the lens."),
# ★ 大头照只写脸/头发/上装(写下装会把镜头拉远 —— 实测)
"consistency": _CONSISTENCY,
"default_bg": "seamless cool-grey studio backdrop",
"default_light": "soft even studio lighting",
"default_expr": "focused gaze",
},
"shoulder_look": {
"label": "回头斜侧半身",
"order": 5,
"is_anchor": False,
"shot": ("Rotate the camera 180 degrees around {his} so {his} back faces the lens, then {he} "
"turns {his} head back over {his} shoulder to look at the camera: visible are {his} "
"back, the back of {his} hair, the back of {his} garment, plus {his} turned face."),
"consistency": _CONSISTENCY,
"default_bg": "seamless cool-grey studio backdrop",
"default_light": "soft even studio lighting",
"default_expr": "calm expression",
},
# ── 全身(2026-10-04 补:全景 / 远景 / 走位镜头需要全身参考)──
"full_body_front": {
"label": "全身正面",
"order": 6,
"is_anchor": False,
"shot": ("Zoom out to a full-body front view: {his} entire figure from the top of {his} head to "
"the soles of {his} shoes is visible, standing naturally with arms relaxed at {his} "
"sides, feet visible, facing the lens squarely."),
"consistency": _CONSISTENCY,
"default_bg": "seamless cool-grey studio backdrop, no cast shadow on the floor",
"default_light": "soft even studio lighting, no harsh split",
"default_expr": "relaxed neutral stance",
},
"full_body_3q": {
"label": "全身 3/4 侧",
"order": 7,
"is_anchor": False,
"shot": ("Zoom out to a full-body three-quarter view: {his} whole figure from head to shoes is "
"visible, body turned about 30-45 degrees toward the image-right, head still turned "
"back toward the lens side, feet visible."),
"consistency": _CONSISTENCY,
"default_bg": "seamless cool-grey studio backdrop, no cast shadow on the floor",
"default_light": "soft front-side lighting showing the body's form",
"default_expr": "relaxed stance, eyes toward the lens",
},
}
# ── 视角顺序(生成用)──
VIEW_ORDER = sorted(PORTRAIT_VIEWS, key=lambda k: PORTRAIT_VIEWS[k]["order"])
# ════════════════════════════════════════════════════════════
# ★ 按镜头类型选参考图 —— 让套装每一张都真正发挥作用
# ════════════════════════════════════════════════════════════
# 背景(2026-10-04 用户指出):套装生成 5~7 张,但代码只取了 front 一张,
# 其余全闲置。正确做法是按镜头景别/角度选**最贴近**的那张:
# · 侧面镜头喂 profile → 侧脸更准
# · 全景/远景喂全身图 → 有全身比例可依
# · 特写喂 face_closeup → 五官细节最准
# 这样在"可灵只能吃一张图"的限制下,把一致性做到最好。
#
# 用关键词匹配(导演输出的描述很长,如「局部特写(腿部至脚趾)」)。
_SIZE_FULL = ("全景", "远景", "全身", "wide", "full", "long", "establish")
_SIZE_CLOSE = ("特写", "大特写", "close", "extreme", "macro")
_ANGLE_SIDE = ("侧", "45", "三分", "profile", "side", "three-quarter")
_ANGLE_BACK = ("背", "回头", "身后", "behind", "over-the-shoulder", "从后")
def pick_reference_for_shot(shot_size: str = "", angle: str = "", refs: dict = None) -> str:
"""按镜头景别/角度选最合适的**参考图槽位名**
refs: 角色资产的 reference_images(如 {"front": "ref_front_half.png", ...})
返回:槽位名(如 "side_45");无可用时返回 ""(调用方回退文生视频)
优先级:全身 > 特写 > 侧面 > 背面 > 半身正面(默认)
"""
refs = refs or {}
if not refs:
return ""
size = str(shot_size or "")
ang = str(angle or "")
def _pick(*keys) -> str:
for k in keys:
if refs.get(k):
return k
return ""
# ① 全景 / 远景 / 全身 → 全身图(没有则退半身)
if any(k in size for k in _SIZE_FULL):
p = _pick("full_body_front", "full_body_3q", "full", "front")
if p:
return p
# ② 特写 → 面部特写
if any(k in size for k in _SIZE_CLOSE):
p = _pick("face_closeup", "front")
if p:
return p
# ③ 侧面 / 45° → 侧视图
if any(k in ang for k in _ANGLE_SIDE):
p = _pick("side_45", "profile", "front")
if p:
return p
# ④ 背面 / 回头 → 回头斜侧
if any(k in ang for k in _ANGLE_BACK):
p = _pick("shoulder_look", "side_45", "front")
if p:
return p
# ⑤ 默认:半身正面(主定妆)
return _pick("front", "front_half") or next(iter(refs.values()), "")
def build_view_prompt(view: str, character_desc: str, bg: str = "", light: str = "",
expression: str = "", extra: str = "") -> str:
"""拼一条视角提示词(**锚点与派生图结构不同**)
锚点(front_half):
{构图几何} + {完整角色描述} + {画风层}
派生图(其余 4 张):
{构图指令} + {短一致性清单} + {画风层}
—— 只写「要改什么」,不重复角色长描述(shuohao 实测:重复会稀释改动)
规范依据:shuohao-skills/character-refs 的 prompting.md(实测定稿)
"""
v = PORTRAIT_VIEWS.get(view) or PORTRAIT_VIEWS["front_half"]
# ★ 人称按角色描述动态注入(修复"男医生被生成成女性")
he, his, him = _detect_pronoun(character_desc)
subst = {"he": he, "He": he.capitalize(), "his": his, "His": his.capitalize(), "him": him}
def _fill(s: str) -> str:
for k, v2 in subst.items():
s = s.replace("{" + k + "}", v2)
return s
look = (f"Backdrop: {bg.strip()}。" if bg else "") + LOOK_REALISM
if v["is_anchor"]:
# 锚点:构图几何 + 完整角色描述 + 画风
desc = (character_desc or "").strip().strip("。,, ")
parts = [_fill(v["shot"]), desc, look]
else:
# 派生图:构图指令 + 短清单 + 画风(不重复长描述)
parts = [_fill(v["shot"]), _fill(v["consistency"]), look]
if expression:
parts.append(f"Expression: {expression.strip().strip('。,, ')}。")
if extra:
parts.append(extra.strip().strip("。,, ") + "。")
return " ".join(p for p in parts if p)
def build_full_set(character_desc: str, bg: str = "", light: str = "",
expressions: dict = None) -> dict:
"""生成完整 5 视角提示词(不调用生成,纯拼接)
返回 {view_id: {"label", "order", "is_anchor", "prompt"}}
"""
expressions = expressions or {}
out = {}
for vid in VIEW_ORDER:
v = PORTRAIT_VIEWS[vid]
out[vid] = {
"label": v["label"],
"order": v["order"],
"is_anchor": v["is_anchor"],
"prompt": build_view_prompt(vid, character_desc, bg=bg, light=light,
expression=expressions.get(vid, "")),
}
return out
def set_summary() -> str:
"""人类可读的套装说明(供前端展示)"""
lines = ["短剧角色参考图套装(5 视角 · 半身)", ""]
for vid in VIEW_ORDER:
v = PORTRAIT_VIEWS[vid]
mark = "★基准" if v["is_anchor"] else " "
lines.append(f" {v['order']}. {mark} {v['label']:16s} {v['composition']}")
lines += [
"",
"流程:先生成「正面半身主定妆」→ 确认满意 → 以它为参考图逐张生成其余 4 张",
"(把主定妆当参考图是五官不崩的关键)",
"",
f"通用负面词:{PORTRAIT_NEGATIVE_ZH}",
]
return "\n".join(lines)
# ── 预设角色包(config/portrait_presets.yaml)──
def _presets_path() -> Path:
return Path(__file__).resolve().parent.parent.parent / "config" / "portrait_presets.yaml"
def load_presets() -> list:
"""读取预设角色包(6 套短剧人设);文件缺失/损坏时返回空列表(不抛错)"""
p = _presets_path()
if not p.exists():
return []
try:
import yaml
d = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
return d.get("presets") or []
except Exception:
return []
def get_preset(preset_id: str) -> dict:
"""按 id 取预设(未知返回空 dict)"""
for x in load_presets():
if x.get("id") == preset_id:
return x
return {}
def preset_to_set(preset_id: str) -> dict:
"""预设 → 完整 5 视角提示词(含该人设的背景/光/神态)"""
ps = get_preset(preset_id)
if not ps:
return {}
return build_full_set(ps.get("desc", ""), bg=ps.get("bg", ""),
light=ps.get("light", ""),
expressions=ps.get("expressions") or {})
# ── CLI ──
if __name__ == "__main__":
import argparse
ap = argparse.ArgumentParser(description="短剧角色参考图套装(提示词预览)")
ap.add_argument("--desc", default="24 岁年轻中国女生,黑茶色中长发,面色苍白,浅蓝条纹病号服")
ap.add_argument("--bg", default="")
ap.add_argument("--light", default="")
ap.add_argument("--json", action="store_true")
args = ap.parse_args()
if args.json:
import json
print(json.dumps(build_full_set(args.desc, args.bg, args.light), ensure_ascii=False, indent=1))
else:
print(set_summary())
print("\n" + "=" * 72)
for vid, item in build_full_set(args.desc, args.bg, args.light).items():
print(f"\n【{item['order']}. {item['label']}】{'★ 主定妆' if item['is_anchor'] else ''}")
print(f" {item['prompt']}")
@@ -0,0 +1,866 @@
"""
提示词组装器 V3 (PromptAssembler) — 模块化10层角色描述系统
架构: 10层模块,从粗到细逐层构建角色
L0 身份层 — 姓名/性别/年龄/种族/世界观
L1 面部轮廓 — 脸型/头型/下颌线/颧骨
L2 五官细节 — 眼(形/色/神)/眉/鼻/嘴/耳
L3 头发层 — 发型/长度/颜色/质地/发际线
L4 皮肤妆容 — 肤色/肤质/底妆/特殊标记/质感
L5 体型姿态 — 身高/体型/肩宽/站姿/手部
L6 服装配饰 — 上装/下装/外套/鞋/配饰
L7 神态表情 — 微表情/眼神/情绪基调/气质
L8 光线镜头 — 光型/色温/镜头/景深/氛围
L9 背景场景 — 环境/背景色/季节/天气
每个层级包含:
- build_{layer}(data) → 自然语言描述片段
- vocab_{layer} → 该层级的完整词汇/选项
- default_{layer} → 默认值
用法:
from prompt_assembler_v3 import CharacterPromptSystem, CHAR_LAYERS
system = CharacterPromptSystem()
# 逐层构建
system.set_layer("L2_face_eyes", {"shape": "丹凤眼", "color": "深褐", "spirit": "锐利"})
# 生成最终提示词
prompt = system.build_prompt(variant="front_face")
# 获取当前完整描述
desc = system.get_layers_descriptions()
# 获取某层的可用选项
options = system.get_layer_options("L2_face_eyes")
"""
from typing import Optional, Any
import json
# ═══════════════════════════════════════════════════════════
# 第零层: L0 — 核心身份
# ═══════════════════════════════════════════════════════════
L0_IDENTITY = {
"label": "核心身份",
"icon": "🆔",
"order": 0,
"fields": [
{"key": "name", "label": "角色名", "type": "text", "placeholder": "如: 阿远"},
{"key": "gender", "label": "性别", "type": "select", "options": ["男性", "女性"]},
{"key": "age", "label": "年龄", "type": "select", "options": ["少年(12-18)", "青年(18-30)", "壮年(30-45)", "中年(45-60)", "老年(60+)"]},
{"key": "ethnicity", "label": "种族/地域", "type": "select", "options": ["中国", "东亚", "东南亚", "南亚", "中东", "欧美", "非洲", "拉美"]},
{"key": "worldview", "label": "世界观/时代", "type": "select", "options": ["现代", "古风", "科幻", "奇幻", "民国", "未来", "赛博朋克"]},
],
"build": lambda d: f"{d.get('name','')},一位{d.get('age','青年').split('(')[0]}{d.get('gender','')},{d.get('ethnicity','')}人,{d.get('worldview','现代')}背景",
}
# ═══════════════════════════════════════════════════════════
# 第一层: L1 — 面部轮廓
# ═══════════════════════════════════════════════════════════
L1_FACE_SHAPE = {
"label": "面部轮廓",
"icon": "🗿",
"order": 1,
"fields": [
{"key": "shape", "label": "脸型", "type": "select",
"options": ["鹅蛋脸", "瓜子脸", "圆脸", "方脸", "国字脸", "长脸", "菱形脸", "心形脸"],
"description": "国字脸/方脸 → 稳重正气;鹅蛋脸 → 温婉;瓜子脸 → 精致秀气"},
{"key": "jaw", "label": "下颌线", "type": "select",
"options": ["柔和模糊", "清晰分明", "棱角突出", "圆润温和"]},
{"key": "cheekbone", "label": "颧骨", "type": "select",
"options": ["适中自然", "高突出", "饱满圆润", "低平"]},
{"key": "forehead", "label": "额头", "type": "select",
"options": ["饱满宽阔", "适中", "窄小", "高额头"]},
],
"build": lambda d: (
f"{d.get('shape','')}"
f"{',' + d.get('jaw','') if d.get('jaw') else ''}"
f"{',' + d.get('cheekbone','') + '颧骨' if d.get('cheekbone') else ''}"
f"{',' + d.get('forehead','') + '额头' if d.get('forehead') else ''}"
),
}
# ═══════════════════════════════════════════════════════════
# 第二层: L2 — 五官细节
# ═══════════════════════════════════════════════════════════
L2_FACIAL_FEATURES = {
"label": "五官细节",
"icon": "👁️",
"order": 2,
"sub_layers": {
"eyes": {
"label": "眼睛",
"icon": "👁️",
"fields": [
{"key": "shape", "label": "眼型", "type": "select",
"options": ["丹凤眼", "杏眼", "桃花眼", "圆眼", "细长眼", "下垂眼", "上挑眼", "单眼皮", "双眼皮", "内双"],
"description": "丹凤眼 → 古典东方美;杏眼 → 圆润可爱;桃花眼 → 含情脉脉"},
{"key": "color", "label": "瞳孔颜色", "type": "select",
"options": ["深褐", "浅褐", "黑色", "琥珀色", "灰色", "蓝色", "绿色"]},
{"key": "size", "label": "大小", "type": "select",
"options": ["大而有神", "适中", "细长", "小而有神"]},
{"key": "spirit", "label": "眼神", "type": "text",
"placeholder": "如: 深邃锐利、清澈温柔、沉稳内敛", "multi": True},
{"key": "detail", "label": "细节补充", "type": "text",
"placeholder": "如: 眼尾微垂、卧蚕明显、睫毛浓密"},
],
"build": lambda d: (
f"{d.get('size','')}{d.get('shape','')}"
f"{'(' + d.get('color','') + '色瞳孔' + ')' if d.get('color') else ''}"
f"{',' + d.get('spirit','') if d.get('spirit') else ''}"
f"{',' + d.get('detail','') if d.get('detail') else ''}"
),
},
"eyebrows": {
"label": "眉毛",
"icon": "🧐",
"fields": [
{"key": "shape", "label": "眉型", "type": "select",
"options": ["剑眉", "柳叶眉", "一字眉", "上挑眉", "弯眉", "粗眉", "细长眉"]},
{"key": "state", "label": "状态", "type": "select",
"options": ["舒展自然", "微微蹙起", "高挑", "平缓"]},
{"key": "color", "label": "眉色", "type": "select",
"options": ["浓黑", "深棕", "浅棕", "自然黑"]},
],
"build": lambda d: (
f"{d.get('shape','')}"
f"{',' + d.get('color','') if d.get('color') else ''}"
f"{',' + d.get('state','') if d.get('state') else ''}"
),
},
"nose": {
"label": "鼻子",
"icon": "👃",
"fields": [
{"key": "bridge", "label": "鼻梁", "type": "select",
"options": ["高挺", "挺直窄小", "中等挺拔", "低平柔和"]},
{"key": "tip", "label": "鼻头", "type": "select",
"options": ["小巧圆润", "圆润饱满", "尖翘", "略宽"]},
{"key": "wing", "label": "鼻翼", "type": "select",
"options": ["适中", "略宽", "窄小"]},
],
"build": lambda d: (
f"鼻梁{d.get('bridge','')}"
f"{',鼻头' + d.get('tip','') if d.get('tip') else ''}"
f"{',鼻翼' + d.get('wing','') if d.get('wing') else ''}"
),
},
"lips": {
"label": "嘴唇",
"icon": "👄",
"fields": [
{"key": "shape", "label": "唇型", "type": "select",
"options": ["薄唇", "厚唇", "M形唇", "饱满", "樱桃小嘴", "微笑唇"]},
{"key": "color", "label": "唇色", "type": "select",
"options": ["自然淡粉", "健康红润", "偏苍白", "暗紫红", "裸色"]},
{"key": "state", "label": "状态", "type": "select",
"options": ["自然闭合", "微张", "紧抿", "嘴角微扬", "含笑意"]},
],
"build": lambda d: (
f"{d.get('shape','')}"
f"{'(' + d.get('color','') + ')' if d.get('color') else ''}"
f"{',' + d.get('state','') if d.get('state') else ''}"
),
},
},
"build": lambda d: "", # 由子层分别构建
}
# ═══════════════════════════════════════════════════════════
# 第三层: L3 — 头发
# ═══════════════════════════════════════════════════════════
L3_HAIR = {
"label": "头发",
"icon": "💇",
"order": 3,
"fields": [
{"key": "style", "label": "发型", "type": "select",
"options": ["短发", "寸头", "中长发", "长发", "背头", "三七分", "齐刘海", "斜刘海",
"大背头", "碎发", "丸子头", "高马尾", "低马尾", "披肩发", "盘发"]},
{"key": "color", "label": "发色", "type": "select",
"options": ["黑色", "深棕", "浅棕", "亚麻色", "灰色", "银白", "渐变挑染"]},
{"key": "texture", "label": "发质", "type": "select",
"options": ["柔顺直发", "自然微卷", "波浪卷", "小卷", "粗硬", "细软", "蓬松"]},
{"key": "length", "label": "长度", "type": "select",
"options": ["板寸(3mm)", "短(3-6cm)", "及耳", "及肩", "及胸", "及腰", "过腰"]},
{"key": "detail", "label": "细节补充", "type": "text",
"placeholder": "如: 两侧收短顶部略长、发际线略高"},
],
"build": lambda d: (
f"{d.get('texture','')}{d.get('style','')}"
f"{'(' + d.get('color','') + ')' if d.get('color') else ''}"
f"{',' + d.get('length','') if d.get('length') else ''}"
f"{',' + d.get('detail','') if d.get('detail') else ''}"
),
}
# ═══════════════════════════════════════════════════════════
# 第四层: L4 — 皮肤与妆容
# ═══════════════════════════════════════════════════════════
L4_SKIN_MAKEUP = {
"label": "皮肤与妆容",
"icon": "✨",
"order": 4,
"fields": [
{"key": "skin_tone", "label": "肤色", "type": "select",
"options": ["冷白皮", "暖白皮", "自然偏白", "健康小麦", "蜜色", "古铜色", "深色"],
"description": "冷白→清冷;暖白→温润;小麦→阳光健康"},
{"key": "skin_texture", "label": "肤质", "type": "select",
"options": ["细腻光滑", "自然肌理", "毛孔可见", "粗糙", "哑光", "水润光泽"],
"description": "人像特写建议 '细腻光滑'+'自然肌理' 组合使用"},
{"key": "complexion", "label": "气色", "type": "select",
"options": ["红润健康", "略显苍白", "疲惫暗沉", "容光焕发", "自然均匀"]},
{"key": "makeup_base", "label": "底妆", "type": "select",
"options": ["素颜", "淡妆", "精致妆容", "烟熏妆", "裸妆", "浓妆"]},
{"key": "makeup_lip", "label": "唇妆", "type": "select",
"options": ["自然唇色", "淡裸色", "豆沙色", "正红色", "暗紫色", "水光唇"]},
{"key": "makeup_eye", "label": "眼妆", "type": "select",
"options": ["无眼妆", "自然眼线", "淡眼影", "烟熏", "猫眼眼线"]},
{"key": "blemish", "label": "特殊标记", "type": "text",
"placeholder": "如: 右眉上细疤、左颊美人痣、法令纹明显、笑纹"},
],
"build": lambda d: (
f"{d.get('skin_tone','')}"
f"{',' + d.get('skin_texture','') if d.get('skin_texture') else ''}"
f"{',' + d.get('complexion','') if d.get('complexion') else ''}"
f"{',' + d.get('makeup_base','') if d.get('makeup_base') else ''}"
f"{'(' + d.get('makeup_eye','') + ',' + d.get('makeup_lip','') + ')' if d.get('makeup_eye') or d.get('makeup_lip') else ''}"
f"{',' + d.get('blemish','') if d.get('blemish') else ''}"
),
}
# ═══════════════════════════════════════════════════════════
# 第五层: L5 — 体型与姿态
# ═══════════════════════════════════════════════════════════
L5_BODY = {
"label": "体型与姿态",
"icon": "🏋️",
"order": 5,
"fields": [
{"key": "height", "label": "身高", "type": "select",
"options": ["矮小(155-165cm)", "中等(165-175cm)", "中高(175-185cm)", "高大(185cm+)"]},
{"key": "build", "label": "体型", "type": "select",
"options": ["纤细瘦弱", "偏瘦", "标准匀称", "健壮", "壮硕", "丰满", "魁梧"]},
{"key": "shoulder", "label": "肩宽", "type": "select",
"options": ["窄肩", "标准", "宽肩", "厚肩"]},
{"key": "posture", "label": "气质姿态", "type": "select",
"options": ["挺拔", "微驼", "自然放松", "端庄", "慵懒", "紧绷"]},
{"key": "hands", "label": "手部", "type": "text",
"placeholder": "如: 手指修长、关节分明、指甲修剪整齐"},
],
"build": lambda d: (
f"身高{d.get('height','中等').split('(')[0]}"
f"体型{d.get('build','')}"
f"{',' + d.get('shoulder','') if d.get('shoulder') else ''}"
f"{',姿态' + d.get('posture','') if d.get('posture') else ''}"
f"{',' + d.get('hands','') if d.get('hands') else ''}"
),
}
# ═══════════════════════════════════════════════════════════
# 第六层: L6 — 服装与配饰
# ═══════════════════════════════════════════════════════════
L6_CLOTHING = {
"label": "服装与配饰",
"icon": "👔",
"order": 6,
"fields": [
{"key": "top", "label": "上装", "type": "text",
"placeholder": "如: 深灰色拉链运动夹克"},
{"key": "inner", "label": "内搭", "type": "text",
"placeholder": "如: 白色圆领速干内衬"},
{"key": "bottom", "label": "下装", "type": "text",
"placeholder": "如: 深蓝色牛仔裤、黑色运动短裤"},
{"key": "outerwear", "label": "外套", "type": "text",
"placeholder": "如: 深藏蓝亚麻外套"},
{"key": "shoes", "label": "鞋子", "type": "text",
"placeholder": "如: 白色跑步鞋"},
{"key": "accessories", "label": "配饰", "type": "text", "multi": True,
"placeholder": "如: 蓝色无线运动耳机(右耳)、细框金属眼镜、简约电子手表、运动护腕"},
{"key": "style_tag", "label": "穿搭风格", "type": "select",
"options": ["运动休闲", "商务正装", "商务休闲", "街头潮流", "日系清新", "简约素雅", "军事工装", "复古"]},
],
"build": lambda d: (
f"身穿{d.get('top','')}"
f"{',内搭' + d.get('inner','') if d.get('inner') else ''}"
f"{',下穿' + d.get('bottom','') if d.get('bottom') else ''}"
f"{',外披' + d.get('outerwear','') if d.get('outerwear') else ''}"
f"{',脚蹬' + d.get('shoes','') if d.get('shoes') else ''}"
f"{',佩戴' + d.get('accessories','') if d.get('accessories') else ''}"
f"{',' + d.get('style_tag','') + '风格' if d.get('style_tag') else ''}"
),
}
# ═══════════════════════════════════════════════════════════
# 第七层: L7 — 神态与表情
# ═══════════════════════════════════════════════════════════
L7_EXPRESSION = {
"label": "神态与表情",
"icon": "😊",
"order": 7,
"fields": [
{"key": "base_mood", "label": "情绪基调", "type": "select",
"options": ["平静从容", "温和友善", "冷峻严肃", "开朗活泼", "忧郁内敛", "自信昂扬", "恬静淡然"]},
{"key": "expression", "label": "表面表情", "type": "select",
"options": ["中性无表情", "淡淡微笑", "开怀大笑", "微笑", "皱眉沉思", "瞪大眼惊讶", "绷着脸"]},
{"key": "eye_spirit", "label": "眼神细节", "type": "text",
"placeholder": "如: 目光坚定直视前方、眼神游离若有所思、眼中带笑意、目光如炬"},
{"key": "micro_expr", "label": "微表情", "type": "text", "multi": True,
"placeholder": "如: 嘴角微微上翘、眉头轻蹙、眼角细纹、鼻翼轻动"},
{"key": "aura", "label": "气质气场", "type": "text",
"placeholder": "如: 沉稳内敛不怒自威、亲和温暖如沐春风、疏离清冷生人勿近"},
],
"build": lambda d: (
f"神色{d.get('base_mood','')}"
f"{',表情' + d.get('expression','') if d.get('expression') else ''}"
f"{',' + d.get('eye_spirit','') if d.get('eye_spirit') else ''}"
f"{',' + d.get('micro_expr','') if d.get('micro_expr') else ''}"
f"{',整体气质' + d.get('aura','') if d.get('aura') else ''}"
),
}
# ═══════════════════════════════════════════════════════════
# 第八层: L8 — 光线与镜头
# ═══════════════════════════════════════════════════════════
L8_LIGHTING_CAMERA = {
"label": "光线与镜头",
"icon": "📸",
"order": 8,
"fields": [
{"key": "light_type", "label": "光型", "type": "select",
"options": ["柔光箱均匀布光", "美人碟柔光", "伦勃朗光", "分割光", "侧光/塑形光",
"逆光/轮廓光", "阴天漫射光", "顶光/戏剧性"],
"description": "美人碟光 → 人像经典;伦勃朗光 → 立体感;分割光 → 戏剧性"},
{"key": "color_temp", "label": "色温", "type": "select",
"options": ["暖色调(金色)", "冷色调(青蓝)", "中性白平衡", "混合色温", "自然日光"]},
{"key": "lens", "label": "镜头焦距", "type": "select",
"options": ["广角(24mm)", "标准(50mm)", "中焦(85mm)", "长焦(135mm)", "微距"]},
{"key": "depth", "label": "景深", "type": "select",
"options": ["浅景深/背景虚化", "中等景深", "大景深/全清晰"]},
{"key": "light_detail", "label": "光线细节", "type": "text",
"placeholder": "如: 光线从右侧45°打来,在左脸形成柔和阴影"},
],
"build": lambda d: (
f"{d.get('light_type','柔光箱均匀布光')}"
f"{',' + d.get('color_temp','') if d.get('color_temp') else ''}"
f"{',' + d.get('lens','') + '镜头' if d.get('lens') else ''}"
f"{',' + d.get('depth','') if d.get('depth') else ''}"
f"{',' + d.get('light_detail','') if d.get('light_detail') else ''}"
),
}
# ═══════════════════════════════════════════════════════════
# 第九层: L9 — 背景与场景
# ═══════════════════════════════════════════════════════════
L9_BACKGROUND = {
"label": "背景与场景",
"icon": "🌄",
"order": 9,
"fields": [
{"key": "bg_type", "label": "背景类型", "type": "select",
"options": ["纯色背景", "影棚布景", "自然户外", "城市街景", "室内空间", "特定场景"]},
{"key": "bg_color", "label": "背景色/氛围", "type": "select",
"options": ["纯白/干净", "纯灰/专业", "纯黑/神秘", "暖色/温馨", "冷色/冷静", "渐变色"]},
{"key": "environment", "label": "具体环境", "type": "text",
"placeholder": "如: 清晨城市街道、江南古巷青砖墙、健身房器械区"},
{"key": "season", "label": "季节/天气", "type": "select",
"options": ["晴天", "阴天", "雨后", "雪天", "黄昏", "深夜", "清晨", "午后"]},
],
"build": lambda d: (
f"{d.get('environment','')}"
f"{',' + d.get('bg_color','') if d.get('bg_color') else ''}"
f"{',' + d.get('season','') if d.get('season') else ''}"
),
}
# ═══════════════════════════════════════════════════════════
# 层级注册表
# ═══════════════════════════════════════════════════════════
CHAR_LAYERS = {
"L0_identity": L0_IDENTITY,
"L1_face_shape": L1_FACE_SHAPE,
"L2_facial_features": L2_FACIAL_FEATURES,
"L3_hair": L3_HAIR,
"L4_skin_makeup": L4_SKIN_MAKEUP,
"L5_body": L5_BODY,
"L6_clothing": L6_CLOTHING,
"L7_expression": L7_EXPRESSION,
"L8_lighting_camera": L8_LIGHTING_CAMERA,
"L9_background": L9_BACKGROUND,
}
# 各子层(L2的各部位)
SUB_LAYERS = {
"L2_face_eyes": L2_FACIAL_FEATURES["sub_layers"]["eyes"],
"L2_face_eyebrows": L2_FACIAL_FEATURES["sub_layers"]["eyebrows"],
"L2_face_nose": L2_FACIAL_FEATURES["sub_layers"]["nose"],
"L2_face_lips": L2_FACIAL_FEATURES["sub_layers"]["lips"],
}
# 所有可配置项合并
ALL_LAYERS = {**CHAR_LAYERS, **SUB_LAYERS}
# ═══════════════════════════════════════════════════════════
# 变体提示词模板
# ═══════════════════════════════════════════════════════════
# ── 正面面部特写(定妆照基准) ──
FRONT_FACE_TEMPLATE_ZH = (
"纯色无缝冷灰摄影棚背景,背景上除了人物什么都没有(无墙、无家具、无道具、无窗、无植物、无风景、无地面阴影)。"
"正面胸像定妆照,面向相机,中性自然表情。" +
"{L0}。{L1}。{eyes}。{eyebrows}。{nose}。{lips}。{L3}。{L4}。{L5}。{L6}。{L7}。{L8}。"
"摄影棚均匀柔光,真实皮肤质感,中性素净不加调色。比例9:16。"
)
FRONT_FACE_TEMPLATE_EN = (
"{L0}. {L1}. {eyes}. {eyebrows}. {nose}. {lips}. {L3}. {L4}. {L5}. {L6}. {L7}. {L8}. {L9}. "
"Professional portrait photography, front-facing camera, neutral expression. "
"Commercial photography quality, 8k resolution, photorealistic style, high detail. --ar 9:16"
)
# ── 全身照 ──
FULL_BODY_TEMPLATE_ZH = (
"纯色无缝冷灰摄影棚背景,背景上除了人物什么都没有(无墙、无家具、无道具、无窗、无植物、无风景、无地面阴影)。"
"全身站立定妆照,头顶到脚底完整入镜,人物居中。" +
"{L0}。{L1}。{eyes}。{L3}。{L4}。{L5}。{L6}。{L7}。"
"摄影棚均匀柔光,真实摄影质感,中性素净。比例9:16。"
)
# ── 网格定妆照 ──
# ── 全身各视角模板(★ 修复:原来 multi_angle 全部错用 front_face 模板)──
# 判据:每个视角必须有自己的构图描述,否则模型只会画同一种正面。
_BODY_BG = ("纯色无缝冷灰摄影棚背景,背景上除了人物什么都没有"
"(无墙、无家具、无道具、无窗、无植物、无风景、无地面阴影)。")
_BODY_STYLE = ("Fujifilm X100V 35mm 镜头直出,unfiltered,"
"真实皮肤质感、毛孔可见、不磨皮不塑料感,摄影棚均匀柔光。")
BODY_ANGLE_TEMPLATES = {
"front": "{desc}。全身正面站立定妆照,从头顶到脚底完整入镜(**必须拍到脚**),"
"双臂自然垂于身侧,面向相机。" + _BODY_BG + _BODY_STYLE + "比例9:16。",
"side": "{desc}。全身左侧面定妆照,从头顶到脚底完整入镜(**必须拍到脚**),"
"正左侧 profile 视角,面向画面右侧。" + _BODY_BG + _BODY_STYLE + "比例9:16。",
"right_three_quarter": "{desc}。全身右前 3/4 侧定妆照,从头顶到脚底完整入镜"
"(**必须拍到脚**),身体朝右前方约 45 度。" + _BODY_BG + _BODY_STYLE + "比例9:16。",
"right_side": "{desc}。全身右侧面定妆照,从头顶到脚底完整入镜(**必须拍到脚**),"
"正右侧 profile 视角,面向画面左侧。" + _BODY_BG + _BODY_STYLE + "比例9:16。",
"back": "{desc}。全身背面定妆照,从正后方拍摄,从头顶到脚踝完整入镜"
"(**必须拍到脚**),看不见脸。" + _BODY_BG + _BODY_STYLE + "比例9:16。",
}
GRID_STANDARD_ZH = (
"角色定妆参考表,三格并排(左:胸像正面;中:全身正面;右:全身背面),"
"三格为同一人,五官发型服装体型完全一致。" + "纯色无缝冷灰摄影棚背景,背景上除了人物什么都没有(无墙、无家具、无道具、无窗、无植物、无风景、无地面阴影)。"
"摄影棚均匀柔光,真实皮肤质感,中性素净不加调色。展示:"
"{L0}。{L1}。{eyes}。{L3}。{L6}。{L7}。比例16:9。"
)
GRID_SPORT_ZH = (
"一张写实风格的角色参考定妆照,展示{L0}。{L1}。{eyes}。{L3}。{L5}。{L6}。{L7}。"
"画面为4列×2行共8格的网格布局,格间有细灰线分隔。"
"上一行:全身正面中性站姿、全身侧面站姿、面部正面中性表情特写、面部四分之三左侧表情特写。"
"下一行:面部纯左侧轮廓特写、面部四分之三右侧表情特写、面部专注坚定眼神特写、面部发力表情特写。"
"8格人物为完全相同的同一人(五官/发型/体型高度一致)。"
"纯白无缝背景,无任何背景元素(无家具/道具/窗/户外风景),人物边界清晰可抠图。"
"真实摄影质感,保留皮肤纹理与毛孔,自然柔和光线,非插画非动漫。比例3:2。"
"负向:场景背景、环境元素、道具、窗户、风景、杂乱背景、文字、水印。"
)
# ── 场景变体 ──
SCENE_TEMPLATE_ZH = (
"{L0}。{L1}。{eyes}。{L3}。{L4}。{L5}。{L6}。{L7}。"
"{scene_desc}。{L8}。{L9}。"
"电影级摄影品质,写实风格。比例9:16"
)
# ── 表情变体 ──
EXPRESSION_TEMPLATE_ZH = (
"面部特写肖像,{L0}。{L1}。{eyes}。{eyebrows}。{nose}。{lips}。{L3}。{L4}。{L6}。"
"{expr_desc}。{L8}。"
"专业影室肖像,纯灰色背景。比例9:16"
)
# ── 负面提示词(通用) ──
NEGATIVE_PROMPT = (
"different face, inconsistent features, changed eye color, altered hair, "
"morphed facial structure, deformed face, asymmetric face, "
"extra limbs, bad anatomy, distorted, blurry, low quality, "
"cartoon, anime, illustration, painting, 3D render"
)
NEGATIVE_PROMPT_ZH = (
"面部不一致,五官改变,眼睛颜色变化,发型变化,"
"面部结构扭曲,面部不对称,多余肢体,解剖错误,"
"模糊,低质量,卡通,动漫,插画,3D渲染"
)
# ═══════════════════════════════════════════════════════════
# CharacterPromptSystem — 角色提示词系统
# ═══════════════════════════════════════════════════════════
class CharacterPromptSystem:
"""模块化10层角色提示词系统"""
def __init__(self):
# 每层的数据存储
self._layers = {}
for layer_id in CHAR_LAYERS:
self._layers[layer_id] = {}
for sub_id in SUB_LAYERS:
self._layers[sub_id] = {}
self._scene_override = ""
self._expression_override = ""
# ── 设置某层数据 ──
def set_layer(self, layer_id: str, data: dict):
"""设置某层的数据"""
if layer_id in self._layers:
self._layers[layer_id].update(data)
else:
self._layers[layer_id] = data
def get_layer(self, layer_id: str) -> dict:
"""获取某层的数据"""
return self._layers.get(layer_id, {})
def set_scene(self, scene_desc: str):
"""设置场景描述(用于场景变体)"""
self._scene_override = scene_desc
def set_expression(self, expr_desc: str):
"""设置表情描述(用于表情变体)"""
self._expression_override = expr_desc
# ── 从自然语言描述批量设置 ──
def from_natural_description(self, desc: str):
"""从自然语言描述中尝试提取各层数据(简易版)"""
# 这个功能由 attribute_extractor 完成更准确
# 这里只做兜底
self._layers["L0_identity"]["_raw"] = desc
# ── 构建提示词 ──
def build_prompt(self, variant: str = "front_face", lang: str = "zh",
mode: str = "standard", **kwargs) -> str:
"""
构建完整的生成提示词
参数:
variant: 变体类型 (front_face|full_body|grid|scene|expression)
lang: 语言
mode: 网格模式 (standard|sport)
**kwargs: 额外参数
返回:
完整提示词字符串
"""
# 先构建各层描述
layers_desc = self._build_layers_descriptions()
if variant == "front_face":
return self._build_front_face(layers_desc, lang)
elif variant == "full_body":
return self._build_full_body(layers_desc, lang)
elif variant == "multi_angle":
angle = kwargs.get("angle") or kwargs.get("expression_name") or "front"
return self._build_multi_angle(layers_desc, lang, angle)
elif variant == "grid":
return self._build_grid(layers_desc, lang, mode)
elif variant == "scene":
scene = kwargs.get("scene_desc", self._scene_override)
return self._build_scene(layers_desc, lang, scene)
elif variant == "expression":
expr = kwargs.get("expr_desc", self._expression_override)
return self._build_expression(layers_desc, lang, expr)
else:
return self._build_front_face(layers_desc, lang)
def _build_layers_descriptions(self) -> dict:
"""构建所有层的描述文本"""
result = {}
# L0 身份
result["L0"] = CHAR_LAYERS["L0_identity"]["build"](self._layers.get("L0_identity", {}))
# L1 面部轮廓
result["L1"] = CHAR_LAYERS["L1_face_shape"]["build"](self._layers.get("L1_face_shape", {}))
# L2 五官子层
for sub_id, sub_def in SUB_LAYERS.items():
result[sub_id.replace("L2_face_", "")] = sub_def["build"](self._layers.get(sub_id, {}))
# L3-L9
for layer_id in ["L3_hair", "L4_skin_makeup", "L5_body",
"L6_clothing", "L7_expression", "L8_lighting_camera", "L9_background"]:
key = layer_id.split("_")[0] # L3, L4, etc
result[key] = CHAR_LAYERS[layer_id]["build"](self._layers.get(layer_id, {}))
return result
def _build_front_face(self, d: dict, lang: str) -> str:
"""构建正面面部特写提示词"""
template = FRONT_FACE_TEMPLATE_ZH if lang == "zh" else FRONT_FACE_TEMPLATE_EN
prompt = template.format(
L0=d.get("L0", ""), L1=d.get("L1", ""),
eyes=d.get("eyes", ""), eyebrows=d.get("eyebrows", ""),
nose=d.get("nose", ""), lips=d.get("lips", ""),
L3=d.get("L3", ""), L4=d.get("L4", ""),
L5=d.get("L5", ""), L6=d.get("L6", ""),
L7=d.get("L7", ""), L8=d.get("L8", ""),
L9=d.get("L9", ""),
)
# 清理空片段留下的多余标点
return self._clean_prompt(prompt)
def _build_full_body(self, d: dict, lang: str) -> str:
"""构建全身照提示词"""
prompt = FULL_BODY_TEMPLATE_ZH.format(
L0=d.get("L0", ""), L1=d.get("L1", ""),
eyes=d.get("eyes", ""), L3=d.get("L3", ""),
L4=d.get("L4", ""), L5=d.get("L5", ""),
L6=d.get("L6", ""), L7=d.get("L7", ""),
) if lang == "zh" else ""
return self._clean_prompt(prompt)
def _build_multi_angle(self, d: dict, lang: str, angle: str) -> str:
"""构建**单视角全身**提示词(front / side / right_three_quarter / right_side / back)
★ 修复说明:原来 variant_map 把 multi_angle 映射成 front_face,
导致 5 个角度全部生成"正面特写" —— 用户看到的"同一张脸重复"就是由此而来。
现在每个角度有自己的构图描述(含"必须拍到脚"的全身边界)。
"""
tpl = BODY_ANGLE_TEMPLATES.get(angle) or BODY_ANGLE_TEMPLATES["front"]
# 人物描述(各层拼接)
desc = self._join_desc(d)
prompt = tpl.format(desc=desc)
return self._clean_prompt(prompt)
@staticmethod
def _join_desc(d: dict) -> str:
"""把各层描述拼成一句人物说明(供全身模板的 {desc} 使用)"""
bits = [d.get(k, "") for k in
("L0", "L1", "eyes", "eyebrows", "nose", "lips",
"L3", "L4", "L5", "L6")]
seen, out = set(), []
for b in bits:
b = (b or "").strip().strip("。,, ")
if b and b not in seen:
seen.add(b); out.append(b)
return ",".join(out)
def _build_grid(self, d: dict, lang: str, mode: str) -> str:
"""构建网格定妆照提示词"""
template = GRID_SPORT_ZH if mode == "sport" else GRID_STANDARD_ZH
prompt = template.format(
L0=d.get("L0", ""), L1=d.get("L1", ""),
eyes=d.get("eyes", ""), L3=d.get("L3", ""),
L5=d.get("L5", ""), L6=d.get("L6", ""),
L7=d.get("L7", ""),
)
return self._clean_prompt(prompt)
def _build_scene(self, d: dict, lang: str, scene: str) -> str:
"""构建场景变体提示词"""
scene_desc = scene or "自然场景"
prompt = SCENE_TEMPLATE_ZH.format(
L0=d.get("L0", ""), L1=d.get("L1", ""),
eyes=d.get("eyes", ""), L3=d.get("L3", ""),
L4=d.get("L4", ""), L5=d.get("L5", ""),
L6=d.get("L6", ""), L7=d.get("L7", ""),
scene_desc=scene_desc,
L8=d.get("L8", ""), L9=d.get("L9", ""),
)
return self._clean_prompt(prompt)
def _build_expression(self, d: dict, lang: str, expr: str) -> str:
"""构建表情变体提示词"""
expr_desc = expr or "中性表情"
prompt = EXPRESSION_TEMPLATE_ZH.format(
L0=d.get("L0", ""), L1=d.get("L1", ""),
eyes=d.get("eyes", ""), eyebrows=d.get("eyebrows", ""),
nose=d.get("nose", ""), lips=d.get("lips", ""),
L3=d.get("L3", ""), L4=d.get("L4", ""),
L6=d.get("L6", ""), expr_desc=expr_desc,
L8=d.get("L8", ""),
)
return self._clean_prompt(prompt)
def _clean_prompt(self, prompt: str) -> str:
"""清理提示词中的多余标点和连续空格"""
import re
# 移除连续的逗号/句号
prompt = re.sub(r'[,,]{2,}', ',', prompt)
prompt = re.sub(r'[。.]{2,}', '。', prompt)
# 移除空括号
prompt = re.sub(r'([^)]*?)', '', prompt)
prompt = re.sub(r'\([^)]*?\)', '', prompt)
# 移除开头的Punctuation
prompt = re.sub(r'^[,。、\s]+', '', prompt)
return prompt.strip()
# ── 获取当前完整自然语言描述 ──
def get_full_description(self) -> str:
"""生成完整的自然语言角色描述(用于知识库回写)"""
d = self._build_layers_descriptions()
parts = []
for key in ["L0", "L1", "eyes", "eyebrows", "nose", "lips",
"L3", "L4", "L5", "L6", "L7"]:
val = d.get(key, "")
if val:
parts.append(val)
desc = ",".join(parts)
return self._clean_prompt(desc)
def get_layer_options(self, layer_id: str) -> dict:
"""获取某层的所有可选项"""
layer = ALL_LAYERS.get(layer_id)
if not layer:
return {}
return {
"label": layer.get("label", ""),
"icon": layer.get("icon", ""),
"fields": layer.get("fields", []),
}
def get_all_layers_config(self) -> dict:
"""获取所有层的配置(供前端渲染)"""
return {
layer_id: self.get_layer_options(layer_id)
for layer_id in ALL_LAYERS
}
def get_variant_types(self) -> dict:
"""获取支持的变体类型"""
return {
"front_face": "正面面部特写",
"full_body": "全身照",
"grid": "网格定妆照",
"scene": "场景变体",
"expression": "表情变体",
}
# ═══════════════════════════════════════════════════════════
# 兼容旧接口(包装器)
# ═══════════════════════════════════════════════════════════
class PromptAssembler:
"""兼容旧版 PromptAssembler 接口"""
def __init__(self):
self._system = CharacterPromptSystem()
def assemble(self, attrs: dict, variant_type: str,
lang: str = "zh", **kwargs) -> str:
"""兼容旧版 assemble 方法"""
# 从旧式 attrs 填充系统
if attrs.get("name") or attrs.get("description"):
self._system.set_layer("L0_identity", {
"name": attrs.get("name", ""),
"gender": "男性" if "男" in attrs.get("description", "") else "女性",
})
face = attrs.get("face", {})
if face:
self._system.set_layer("L2_face_eyes", {
"shape": face.get("eyes", ""),
"spirit": "",
})
self._system.set_layer("L2_face_nose", {
"bridge": face.get("nose", ""),
})
self._system.set_layer("L2_face_lips", {
"shape": face.get("lips", ""),
})
if face.get("hair"):
self._system.set_layer("L3_hair", {"style": face["hair"]})
if face.get("skin"):
self._system.set_layer("L4_skin_makeup", {"skin_tone": face["skin"]})
clothing = attrs.get("clothing", {})
if clothing:
self._system.set_layer("L6_clothing", {
"top": clothing.get("top", ""),
"inner": clothing.get("inner", ""),
})
personality = attrs.get("personality", "")
if personality:
self._system.set_layer("L7_expression", {"base_mood": personality})
# 映射变体类型
variant_map = {
"front_face": "front_face",
"full_body": "full_body",
"multi_angle": "multi_angle", # ★ 修复:原来错映射成 front_face(5 个角度全画正面)
"expression": "expression",
"scene": "scene",
"grid": "grid",
"sport_action": "scene",
}
mapped_variant = variant_map.get(variant_type, "front_face")
mode = kwargs.get("mode", "standard")
expr = kwargs.get("expression_name", "")
scene = kwargs.get("scene_name", "")
if expr:
self._system.set_expression(expr)
if scene:
self._system.set_scene(scene)
return self._system.build_prompt(mapped_variant, lang, mode=mode)
def get_available_variants(self) -> dict:
"""兼容旧接口"""
return self._system.get_variant_types()
if __name__ == "__main__":
# 测试
system = CharacterPromptSystem()
# 逐层设置
system.set_layer("L0_identity", {"name": "阿远", "gender": "男性", "age": "青年(18-30)", "ethnicity": "中国"})
system.set_layer("L1_face_shape", {"shape": "国字脸", "jaw": "清晰分明"})
system.set_layer("L2_face_eyes", {"shape": "单眼皮", "color": "深褐", "spirit": "锐利深邃"})
system.set_layer("L2_face_eyebrows", {"shape": "剑眉", "state": "舒展自然"})
system.set_layer("L2_face_nose", {"bridge": "高挺", "tip": "圆润饱满"})
system.set_layer("L2_face_lips", {"shape": "微笑唇", "state": "嘴角微扬"})
system.set_layer("L3_hair", {"style": "短发", "color": "黑色", "detail": "两侧收短顶部略长"})
system.set_layer("L4_skin_makeup", {"skin_tone": "健康小麦", "skin_texture": "自然肌理"})
system.set_layer("L5_body", {"build": "健壮", "shoulder": "宽肩", "posture": "挺拔"})
system.set_layer("L6_clothing", {"top": "深灰色拉链运动夹克", "inner": "白色速干内衬", "accessories": "蓝色无线运动耳机(右耳)"})
system.set_layer("L7_expression", {"base_mood": "沉稳内敛", "eye_spirit": "目光坚定直视前方"})
system.set_layer("L8_lighting_camera", {"light_type": "柔光箱均匀布光", "color_temp": "暖色调(金色)"})
system.set_layer("L9_background", {"bg_type": "纯色背景", "bg_color": "纯灰/专业"})
print("=== 正面面部特写 ===")
print(system.build_prompt("front_face"))
print("\n=== 网格定妆照 ===")
print(system.build_prompt("grid", mode="standard"))
print("\n=== 完整描述 ===")
print(system.get_full_description())
print("\n=== 前端配置数据 ===")
config = system.get_all_layers_config()
for layer_id, cfg in config.items():
print(f" {layer_id}: {cfg['icon']} {cfg['label']} ({len(cfg.get('fields',[]))} 字段)")
@@ -0,0 +1,89 @@
# 角色定妆照提示词库
### 夺
- **描述**: 奇才
- **面部**: 脸型=, 眼睛=, 发型=, 肤色=
- **体型**: 体态=, 身高=
- **性格**: 默认
- **语音**: 默认 # 自动注册)
### 测试
- **描述**: 测试角色描述
- **面部**: 脸型=, 眼睛=, 发型=, 肤色=
- **体型**: 体态=, 身高=
- **性格**: 默认
- **语音**: 默认 # 自动注册)
### 测试角色
- **描述**: 测试方向描述
- **面部**: 脸型=, 眼睛=, 发型=, 肤色=
- **体型**: 体态=, 身高=
- **性格**: 默认
- **语音**: 默认 # 自动注册)
### 测试角色2
- **描述**: 测试方向描述
- **面部**: 脸型=, 眼睛=, 发型=, 肤色=
- **体型**: 体态=, 身高=
- **性格**: 默认
- **语音**: 默认 # 自动注册)
### 中国
- **描述**: 【身份】女性、青年、中国、张三
【面部轮廓】适中自然、适中
【五官细节】深褐、高挺、适中、自然闭合
【头发&皮肤】短发、黑色、健康小麦、自然肌理、红润健康、素颜
【体型姿态】标准、挺拔
【神态&场景】柔光箱均匀布光、中焦85mm、纯灰色专业背景
- **面部**: 脸型=, 眼睛=, 发型=【身份】女性、青年、中国、张三
【面部轮廓】适中自然、适中
【五官细节】深褐、高挺、适中、自然闭合
【头发&皮肤】短发、黑色、健康小麦、自然肌理、红润健康、素颜
【体型姿态】标准、挺拔
【神态&场景】柔光箱均匀布光、中焦85mm、纯灰色专业背景, 肤色=健康小麦、自然肌理、红润健康、素颜
【体型姿态】标准、挺拔
【神态&场景】柔光箱均匀布光、中焦85mm、纯灰色专业背景
- **体型**: 体态=标准, 身高=
- **性格**: 默认
- **语音**: 默认 # 自动注册)
### 台湾
- **描述**: 【身份】青年、中国、少女
【面部轮廓】适中自然、适中
【五官细节】深褐、高挺、适中、自然闭合
【头发&皮肤】短发、黑色、健康小麦、自然肌理、红润健康、素颜
【体型姿态】标准、挺拔
【神态&场景】柔光箱均匀布光、中焦85mm、纯灰色专业背景
- **面部**: 脸型=, 眼睛=, 发型=【身份】青年、中国、少女
【面部轮廓】适中自然、适中
【五官细节】深褐、高挺、适中、自然闭合
【头发&皮肤】短发、黑色、健康小麦、自然肌理、红润健康、素颜
【体型姿态】标准、挺拔
【神态&场景】柔光箱均匀布光、中焦85mm、纯灰色专业背景, 肤色=健康小麦、自然肌理、红润健康、素颜
【体型姿态】标准、挺拔
【神态&场景】柔光箱均匀布光、中焦85mm、纯灰色专业背景
- **体型**: 体态=标准, 身高=
- **性格**: 默认
- **语音**: 默认 # 自动注册)
### test
- **描述**: 【身份】男性
【面部】标准
- **面部**: 脸型=, 眼睛=, 发型=, 肤色=
- **体型**: 体态=标准, 身高=
- **性格**: 默认
- **语音**: 默认 # 自动注册)
@@ -0,0 +1,361 @@
"""
character_generator/quality_check.py — 角色参考图「生成后质检」
## 为什么需要
实测发现:角色参考图若带场景背景,图生视频时模型会把背景一起"绑定"到角色上
→ 生成的画面环境错乱、跨镜不一致。**参考图必须是干净背景的定妆照**。
实测(2026-10-03,用户角色库):
害羞美女患者 边缘标准差 47.1 ❌ 有场景
麻醉医生_帅气男 边缘标准差 52.3 ❌ 有场景
找回的2张生成图 58.4 / 51.2 ❌ 有场景
中国 16.8 较干净
## 判据
取图片**四条边缘**的像素,统计颜色标准差:
· 纯色背景 → 边缘高度一致 → 标准差小(< 12)
· 带场景 → 边缘颜色杂乱 → 标准差大(> 25)
无需 AI 模型,纯本地计算(PIL),零成本。
## 用法
from character_generator.quality_check import check_reference_quality
r = check_reference_quality("/path/to/ref.png")
# {"ok": bool, "score": 0-100, "edge_std": float, "issues": [...], "suggestion": str}
"""
from pathlib import Path
try:
from PIL import Image, ImageStat
except ImportError: # pragma: no cover
Image = None
# 阈值(实测校准)
EDGE_CLEAN = 12.0 # 标准差 < 12 → 干净(纯色背景)
EDGE_ACCEPT = 25.0 # < 25 → 可接受;≥ 25 → 判定有场景
MIN_SIDE = 512 # 短边最小像素(定妆照清晰度下限)
def check_reference_quality(path: str) -> dict:
"""检查角色参考图是否合格(背景是否干净 + 分辨率)
返回:
ok bool 是否合格
score int 0-100(越高越干净)
edge_std float 边缘颜色标准差(越小越干净)
width/height int
issues list[str]
suggestion str 不合格时的修改建议
"""
p = Path(path)
if not p.exists():
return {"ok": False, "score": 0, "edge_std": -1, "issues": [f"文件不存在: {path}"],
"suggestion": "重新生成或重新上传参考图"}
if Image is None:
return {"ok": True, "score": 60, "edge_std": -1,
"issues": ["未安装 Pillow,跳过背景检测(pip install pillow)"],
"suggestion": ""}
try:
im = Image.open(p).convert("RGB")
except Exception as e:
return {"ok": False, "score": 0, "edge_std": -1,
"issues": [f"图片无法解码: {e}"], "suggestion": "重新上传标准 jpg/png"}
w, h = im.size
# ⚠️ 用**四角**采样(而非四条边):
# 半身像/人像的人物常顶到上下边缘,用整边采样必然采到人物 → 误判"背景脏"。
# 四角是纯背景概率最高的区域(实测:竖屏半身像整边 29.2 → 四角 6~12)
band = max(6, min(w, h) // 18)
pad = max(2, band // 6)
corners = [
(pad, pad, pad + band, pad + band), # 左上
(w - pad - band, pad, w - pad, pad + band), # 右上
(pad, h - pad - band, pad + band, h - pad), # 左下
(w - pad - band, h - pad - band, w - pad, h - pad), # 右下
]
stds = []
for box in corners:
try:
st = ImageStat.Stat(im.crop(box))
stds.append(sum(st.stddev) / 3.0)
except Exception:
continue
edge_std = round(sum(stds) / len(stds), 1) if stds else -1.0
# 四角平均色的通道离散度(辅助判断"是否纯色背景")
try:
mean_of_edges = [0.0, 0.0, 0.0]
for box in corners:
m = ImageStat.Stat(im.crop(box)).mean
for i in range(3):
mean_of_edges[i] += m[i] / len(corners)
chan_spread = round(max(mean_of_edges) - min(mean_of_edges), 1)
except Exception:
chan_spread = 0.0
issues: list[str] = []
if min(w, h) < MIN_SIDE:
issues.append(f"分辨率偏低({w}×{h},短边建议 ≥ {MIN_SIDE}px)")
if edge_std >= EDGE_ACCEPT:
issues.append(f"背景不干净(边缘颜色标准差 {edge_std},≥{EDGE_ACCEPT} 判定含场景元素)")
elif edge_std >= EDGE_CLEAN:
issues.append(f"背景不够纯净(边缘标准差 {edge_std},建议 < {EDGE_CLEAN})")
# 打分:边缘越干净分越高
if edge_std < 0:
score = 60
else:
score = int(max(0, min(100, 100 - (edge_std / EDGE_ACCEPT) * 100))) if edge_std < EDGE_ACCEPT \
else int(max(0, 40 - (edge_std - EDGE_ACCEPT)))
ok = not any(i.startswith("背景不干净") for i in issues) and min(w, h) >= MIN_SIDE
suggestion = ""
if not ok:
if edge_std >= EDGE_ACCEPT:
suggestion = (
"参考图带场景背景 → 图生视频时模型会把背景绑定到角色上。"
"建议:用「标准定妆照」模式重新生成(纯白无缝背景 + 多视角网格),"
"或在生成器的「🌄 背景与场景」里把环境/季节清空、背景类型选「纯色背景」。"
)
else:
suggestion = "建议提高分辨率或改用纯色背景的定妆照。"
return {
"ok": ok,
"score": score,
"edge_std": edge_std,
"chan_spread": chan_spread,
"width": w,
"height": h,
"issues": issues,
"suggestion": suggestion,
}
def is_clean_reference(path: str) -> bool:
"""便捷判定:参考图是否干净可用"""
return bool(check_reference_quality(path).get("ok"))
# ════════════════════════════════════════════════════════════
# ★ 参考图「白底清理」(后处理)—— 消除场景背景
# ════════════════════════════════════════════════════════════
# 外网参考(muse-character-sheet / style-consistency-ai):
# "每个姿势都要跑一遍白底清理(ComfyUI-RMBG)" ——
# 后处理抠图换底,比指望模型自己画干净背景**可靠得多**。
#
# 实现:rembg(本地 U2Net)+ 纯色底合成,零 API 成本。
# 模型(~176MB)首次自动下载;不可用时优雅降级(不阻塞流程)。
_BG_COLORS = {
"cool_grey": (132, 138, 145), # 研究结论:冷灰优于纯白(白底会削平高光)
"white": (255, 255, 255),
"light_grey": (225, 227, 230),
}
_rembg_available = None
_rembg_error = ""
def _check_rembg() -> tuple:
"""检查 rembg 是否可用(惰性 + 结果缓存)"""
global _rembg_available, _rembg_error
if _rembg_available is not None:
return _rembg_available, _rembg_error
try:
from rembg import remove # noqa: F401
_rembg_available = True
except Exception as e:
_rembg_available = False
_rembg_error = f"rembg 不可用: {e}(pip install rembg onnxruntime)"
return _rembg_available, _rembg_error
# 模型文件的最小字节数(判断下载是否完整)
_MODEL_REQ = {"u2net": 50_000_000, "isnet-general-use": 40_000_000, "u2netp": 3_000_000}
def pick_rembg_model() -> str:
"""选一个**已就绪**的抠图模型(大模型优先,回退小模型)
· u2net ≈176MB(效果好)
· u2netp ≈4.7MB(快,效果略差)—— 网络受限时先下小的即可用起来
返回 "" 表示没有可用模型(调用方跳过清理)。
"""
home = Path.home() / ".u2net"
for name in ("u2net", "isnet-general-use", "u2netp"):
f = home / f"{name}.onnx"
try:
if f.exists() and f.stat().st_size >= _MODEL_REQ.get(name, 1_000_000):
return name
except Exception:
continue
return ""
def clean_reference_background(src_path: str, dst_path: str = "",
bg: str = "cool_grey", keep_alpha: bool = False) -> dict:
"""把参考图抠出来合成到纯色背景(消除场景背景)
这是**后处理**方案 —— 比指望模型生成干净背景更可靠。
⚠️ 首次调用会下载 U2Net 模型(~176MB),耗时较长;之后走缓存。
参数:
bg cool_grey(推荐)| white | light_grey
keep_alpha 保留透明通道(不合成背景,输出 PNG)
返回:
{ok, output, before, after, improved, skipped, reason}
· skipped=True 表示环境不具备(**不阻塞流程**,调用方直接用原图即可)
"""
ok_rb, err = _check_rembg()
if not ok_rb:
return {"ok": False, "skipped": True, "reason": err, "output": src_path}
model_name = pick_rembg_model()
if not model_name:
return {"ok": False, "skipped": True,
"reason": "抠图模型未就绪(需 u2net.onnx 或 u2netp.onnx 放入 ~/.u2net/)",
"output": src_path, "model": ""}
src = Path(src_path)
if not src.exists():
return {"ok": False, "skipped": True, "reason": "源文件不存在", "output": src_path}
dst = Path(dst_path) if dst_path else src.with_name(f"{src.stem}_clean.png")
before = check_reference_quality(str(src))
try:
from rembg import remove, new_session
from PIL import Image
im = Image.open(src).convert("RGB")
_session = new_session(model_name) # ★ 用选中的模型(u2net / u2netp)
cut = remove(im, session=_session) # → RGBA
if keep_alpha:
cut.save(dst)
else:
color = _BG_COLORS.get(bg) or _BG_COLORS["cool_grey"]
canvas = Image.new("RGB", cut.size, color)
canvas.paste(cut, (0, 0), cut if cut.mode in ("RGBA", "LA") else None)
canvas.save(dst)
after = check_reference_quality(str(dst))
improved = (before.get("edge_std", 999) - after.get("edge_std", 999))
return {
"ok": True, "output": str(dst),
"before": {"edge_std": before.get("edge_std"), "score": before.get("score"),
"ok": before.get("ok")},
"after": {"edge_std": after.get("edge_std"), "score": after.get("score"),
"ok": after.get("ok")},
"improved": round(improved, 1),
}
except Exception as e:
return {"ok": False, "skipped": False, "reason": f"抠图失败: {str(e)[:200]}",
"output": src_path, "before": {"edge_std": before.get("edge_std")}}
# ── CLI:批量体检角色库 ──
if __name__ == "__main__":
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from assets_manager import AssetStore
st = AssetStore("characters")
items = st.list()
print(f"=== 角色参考图质检({len(items)} 个角色)===\n")
bad = 0
for it in items:
cid = it.get("id")
refs = it.get("reference_images") or {}
if not refs:
print(f" ⚠️ {cid:16s} 无参考图")
bad += 1
continue
for slot, fname in refs.items():
p = Path(st.kind_dir(cid)) / Path(str(fname)).name
r = check_reference_quality(str(p))
mark = "✅" if r["ok"] else "❌"
print(f" {mark} {cid:16s} [{slot:8s}] 分 {r['score']:3d} | 边缘标准差 {r['edge_std']:5.1f} | {r['width']}x{r['height']}")
for i in r["issues"]:
print(f" · {i}")
if not r["ok"]:
bad += 1
print(f"\n不合格 {bad} 张 —— 建议重新生成(定妆照模式)")
# ── 网格定妆照专用质检(★ 按格检查)────────────────────────────
# 为什么单写一个:整图边缘检测对**多格图无效** ——
# 格子越多,图片边缘越必然包含相邻格的内容,导致误判为"脏背景"。
# 实测教训(2026-10-03):三格版 14.2 ✅ / 四格版 50.1 ❌,
# 两者提示词质量相近,差异主要来自"格数影响了边缘采样"。
def check_sheet_quality(path: str, grid: tuple = None) -> dict:
"""网格定妆照质检:**逐格检查背景**(每格取四角),而非整图边缘
⚠️ **只适用于真正的网格图**(多格并排)。判据与方法必须配套:
· 单张人像图 → 用 check_reference_quality()(整图边缘)
· 网格图 → 用本函数(按格检查)
两者不可混用来比较不同格式的方案(实测教训,2026-10-03)。
grid: (cols, rows) —— **强烈建议显式传入**;不传会按宽高比猜(可能猜错,
把单图当成网格切开,导致误判为"干净")
返回 {ok, score, grid, median_cell_std, worst_cell_std, per_cell, issues}
"""
p = Path(path)
if not p.exists() or Image is None:
return {"ok": False, "score": 0, "median_cell_std": -1, "worst_cell_std": -1,
"per_cell": [], "issues": ["文件不存在或缺少 Pillow"]}
try:
im = Image.open(p).convert("RGB")
except Exception as e:
return {"ok": False, "score": 0, "median_cell_std": -1, "worst_cell_std": -1,
"per_cell": [], "issues": [f"无法解码: {e}"]}
w, h = im.size
if grid is None:
ar = w / max(1, h)
if ar >= 1.7:
grid = (4, 1)
elif ar >= 1.4:
grid = (3, 1)
elif ar >= 1.15:
grid = (2, 1)
else:
grid = (2, 2)
cols, rows = grid
cw, ch = w // cols, h // rows
band = max(3, min(cw, ch) // 40)
per_cell = []
for r in range(rows):
for c in range(cols):
x0, y0 = c * cw, r * ch
pad = max(2, band // 2)
corners = [
(x0 + pad, y0 + pad, x0 + pad + band, y0 + pad + band),
(x0 + cw - pad - band, y0 + pad, x0 + cw - pad, y0 + pad + band),
(x0 + pad, y0 + ch - pad - band, x0 + pad + band, y0 + ch - pad),
(x0 + cw - pad - band, y0 + ch - pad - band, x0 + cw - pad, y0 + ch - pad),
]
stds = []
for box in corners:
try:
st = ImageStat.Stat(im.crop(box))
stds.append(sum(st.stddev) / 3.0)
except Exception:
continue
cell_std = round(sum(stds) / len(stds), 1) if stds else -1.0
per_cell.append({"cell": f"r{r+1}c{c+1}", "edge_std": cell_std})
valid = [c["edge_std"] for c in per_cell if c["edge_std"] >= 0]
srt = sorted(valid)
med = srt[len(srt) // 2] if srt else -1.0
worst = max(valid) if valid else -1.0
ok = med < EDGE_ACCEPT
score = int(max(0, min(100, 100 - (med / EDGE_ACCEPT) * 100))) if med >= 0 else 0
issues = []
if not ok:
issues.append(f"多数格的背景不干净(各格中位标准差 {med},阈值 <{EDGE_ACCEPT})")
return {"ok": ok, "score": score, "grid": grid,
"median_cell_std": med, "worst_cell_std": worst, "per_cell": per_cell,
"issues": issues}
@@ -0,0 +1,351 @@
"""
变体生成器 (VariantGenerator) — 按变体类型生成角色图像
使用 Kling API 批量生成各类变体图像:
- front_face: 正面高清面部特写
- full_body: 全身正面
- multi_angle: 多角度全身(front/side/right_three_quarter/right_side/back)
- expression: 表情变体(neutral/smile/focused/laugh/serious)
- scene: 场景变体
- sport_action: 运动姿态
- grid: 网格定妆照(标准2×3/运动4×2)
每张生成结果以 {role_name}_{variant_type}_{variant_option}.png 命名和缓存
"""
import json
import hashlib
import time
import os
import sys
from pathlib import Path
from typing import Optional
path = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, path)
from lib.config import load_config
from lib.logger import get_logger
from character_generator.prompt_assembler import PromptAssembler
logger = get_logger('variant_generator')
environ = os.environ.get
home = os.path.expanduser('~')
# 缓存目录
CACHE_DIR = environ(
'AVE_CACHE_DIR',
os.path.join(home, 'workbuddy-agent-os/agent-local/tools/ave/cache/character_portrait'),
)
os.makedirs(CACHE_DIR, exist_ok=True)
# Kling API 基础 URL
KLING_BASE = 'https://api-beijing.klingai.com'
class VariantGenerator:
"""使用 Kling API 批量生成角色变体图像。"""
def __init__(self):
self._assembler = PromptAssembler()
self._cfg = load_config()
# ══════════════════════════════════════════════════
# 主入口
# ══════════════════════════════════════════════════
def generate(
self,
attrs: dict,
variant_type: str,
variant_option: str,
lang: str = 'zh',
force: bool = False,
seed: int = 0,
) -> dict:
"""生成指定变体类型的图像。
Args:
attrs: 结构化属性字典
variant_type: 变体类型 (front_face, full_body, multi_angle, expression, ...)
variant_option: 变体选项(如 angle_name, expression_name 等)
lang: 语言
force: 强制重新生成
seed: 固定种子
Returns:
{"path": str, "url": str, "seed": int, "prompt": str, "cached": bool}
"""
# 组装提示词
prompt = self._assembler.build(variant_type, variant_option, attrs)
# 生成缓存 key
opt_suffix = variant_option.replace(' ', '_')
cache_key = f'{attrs.get("name", "char")}_{variant_type}_{opt_suffix}'
output_path = os.path.join(CACHE_DIR, cache_key)
Path(output_path).mkdir(parents=True, exist_ok=True)
filename = f'{cache_key}_{seed if seed else "rand"}.png'
filepath = os.path.join(output_path, filename)
# 如果缓存命中且不强制,直接返回
if not force and os.path.exists(filepath):
logger.info(f' 缓存命中: {filepath}')
return {'path': filepath, 'url': '', 'seed': seed, 'prompt': prompt, 'cached': True}
# 调用 Kling API
result = self._generate_kling(prompt, filepath, seed)
result['prompt'] = prompt
return result
# ══════════════════════════════════════════════════
# 批量生成
# ══════════════════════════════════════════════════
def generate_all_angles(
self,
attrs: dict,
lang: str = 'zh',
force: bool = False,
seed: int = 0,
) -> dict:
"""生成所有角度的全身图。"""
angles = ('front', 'side', 'right_three_quarter', 'right_side', 'back')
results = {}
for angle in angles:
results[angle] = self.generate(attrs, 'multi_angle', angle, lang, force, seed)
return results
def generate_all_expressions(
self,
attrs: dict,
lang: str = 'zh',
force: bool = False,
seed: int = 0,
) -> dict:
"""生成所有表情变体。"""
expressions = ('neutral', 'smile', 'focused', 'laugh', 'serious')
results = {}
for expr in expressions:
results[expr] = self.generate(attrs, 'expression', expr, lang, force, seed)
return results
def generate_baseline_portrait(
self,
attrs: dict,
lang: str = 'zh',
force: bool = False,
seed: int = 0,
) -> dict:
"""生成正面基准肖像(用于 face_anchor)。"""
return self.generate(attrs, 'front_face', 'baseline', lang, force, seed)
def generate_grid_sheet(
self,
attrs: dict,
mode: str = 'standard',
lang: str = 'zh',
force: bool = False,
seed: int = 0,
ref_image: str = '',
prompt: str = '',
) -> dict:
"""生成网格定妆照。
Args:
attrs: 结构化属性
mode: 网格模式 (standard=2x3, sport=4x2)
lang: 语言
force: 强制重新生成
seed: 固定种子
ref_image: 参考图路径(可选)
prompt: 手动提示词(可选,为空则自动组装)
"""
if not prompt:
prompt = self._assembler.build('grid', mode, attrs, lang=lang)
name = attrs.get('name', 'char')
output_path = os.path.join(CACHE_DIR, 'grid', name)
Path(output_path).mkdir(parents=True, exist_ok=True)
filename = f'grid_{name}_{mode}_{seed if seed else "rand"}.png'
filepath = os.path.join(output_path, filename)
if not force and os.path.exists(filepath):
logger.info(f' 网格缓存命中: {filepath}')
return {'path': filepath, 'url': '', 'seed': seed, 'prompt': prompt, 'cached': True}
result = self._generate_kling(prompt, filepath, seed, ref_image)
result['prompt'] = prompt
return result
# ══════════════════════════════════════════════════
# 辅助
# ══════════════════════════════════════════════════
def _get_option_key(self, variant_type: str) -> str:
"""将变体类型映射到配置中的选项 key。"""
mapping = {
'multi_angle': 'angles',
'expression': 'expressions',
'scene': 'scenes',
'sport_action': 'actions',
'outfit': 'outfits',
'grid': 'grid_modes',
}
return mapping.get(variant_type, variant_type)
# ══════════════════════════════════════════════════
# Kling API 底层
# ══════════════════════════════════════════════════
def _generate_kling(
self,
prompt: str,
output_path: str,
seed: int = 0,
ref_image: str = '',
) -> dict:
"""调用 Kling API 生成图像。
支持 T2I(无 ref_image)和 I2I(有 ref_image)。
Args:
prompt: 生成提示词
output_path: 输出文件路径
seed: 固定种子(0 = 随机)
ref_image: 参考图路径(可选,走 I2I)
Returns:
{"path": str, "seed": int, "filename": str}
失败时返回 {"failed": True, "error": str}
"""
import httpx
# 加载 Kling 配置
kling_cfg = self._cfg.get('kling', {})
ak = kling_cfg.get('access_key', environ('KLING_ACCESS_KEY', ''))
sk = kling_cfg.get('secret_key', environ('KLING_SECRET_KEY', ''))
if not ak or not sk:
raise ValueError('缺少 Kling API 配置 (kling.access_key / kling.secret_key)')
# 生成 JWT token
import jwt
now = int(time.time())
token = jwt.encode(
{'iss': ak, 'exp': now + 1800, 'nbf': now},
sk,
algorithm='HS256',
)
headers = {
'Authorization': f'Bearer {token}',
'Content-Type': 'application/json',
}
# 判断是否用参考图(I2I)
use_ref = bool(ref_image and os.path.exists(ref_image))
if use_ref:
with open(ref_image, 'rb') as f:
import base64
img_b64 = base64.b64encode(f.read()).decode('utf-8')
payload = {
'model': 'kling-v1',
'prompt': prompt,
'image': img_b64,
'strength': 0.65,
'aspect_ratio': '9:16',
'n': 1,
}
else:
payload = {
'model': 'kling-v3',
'prompt': prompt,
'aspect_ratio': '9:16',
'n': 1,
}
if seed:
payload['seed'] = seed
# 提交任务
for attempt in range(3):
try:
resp = httpx.post(
f'{KLING_BASE}/v1/images/generations',
headers=headers,
json=payload,
timeout=30,
)
if resp.status_code == 429:
wait = 15 * (attempt + 1)
logger.warning(f' 429 限流,等待 {wait}s...')
time.sleep(wait)
continue
resp.raise_for_status()
data = resp.json()
task_id = data.get('data', {}).get('task_id', '')
if not task_id:
raise RuntimeError(f'Kling 提交失败: {data}')
break
except Exception as e:
if attempt == 2:
raise
time.sleep(5)
else:
raise RuntimeError('Kling API 提交失败(重试耗尽)')
# 轮询任务状态
for i in range(40):
time.sleep(5)
try:
q = httpx.get(
f'{KLING_BASE}/v1/images/generations/{task_id}',
headers=headers,
timeout=30,
)
q.raise_for_status()
status_data = q.json()['data']
task_status = status_data.get('task_status', '')
if task_status == 'succeed':
images = status_data.get('task_result', {}).get('images', [])
if not images:
raise RuntimeError('Kling 生成成功但无图像')
img_url = images[0].get('url', '')
returned_seed = images[0].get('seed', seed)
# 下载图像
img_resp = httpx.get(img_url, timeout=30)
img_resp.raise_for_status()
Path(os.path.dirname(output_path)).mkdir(parents=True, exist_ok=True)
with open(output_path, 'wb') as f:
f.write(img_resp.content)
logger.info(f' ✅ Kling 生成完成: {os.path.basename(output_path)} [seed={returned_seed}]')
return {
'path': output_path,
'seed': returned_seed,
'filename': os.path.basename(output_path),
}
elif task_status == 'failed':
reason = status_data.get('fail_reason', '未知错误')
raise RuntimeError(f'Kling 生成失败: {reason}')
except Exception as e:
logger.warning(f' 轮询异常: {e}')
time.sleep(5)
raise TimeoutError('Kling 生成超时(200s)')
if __name__ == '__main__':
gen = VariantGenerator()
print('变体生成器初始化完成')
available = gen.get_available_variants()
print(f'可用变体: {list(available.keys())}')
@@ -0,0 +1,790 @@
"""
AVE character_portrait — 逐帧参考链定妆照生成(v2.0 重写)
核心原则:
⚠️ 前一轮的问题:所有角度共用同一张基准图作 I2I 参考 → 角色漂移 + 方向混乱
✅ 本方案:逐帧参考链(Sequential Reference Chain)
图1(正面T2I) → 作为图2参考 → 图2(右半侧I2I) → 作为图3参考 → 图3(右侧面I2I) → 作为图4参考 → 图4(背面I2I)
每一步只做"小幅姿态变化",最大限度保持角色一致性
流程:
Phase 1: Kling T2I (kling-v3, 高质量) 生成正面全身基准图
Phase 2: 逐帧 I2I 参考链,小步幅递增
图1(正面) → 图2(右侧45°半侧面, I2I参考图1)
图2(右侧45°) → 图3(纯右侧面, I2I参考图2)
图3(右侧面) → 图4(背面, I2I参考图3)
Phase 3: 注册到 CharacterRegistry(face_anchor + 各角度图)
参考强度逐级递增:0.70 → 0.75 → 0.70(近端参考 = 更高一致性)
用法:
python character_portrait.py --character "陆晨晖" --desc "..."
API调用:
POST /api/characters/generate-portrait
{"character_name": "陆晨晖", "description": "...", "force": false}
"""
import sys
import os
import json
import time
import base64
import hashlib
from pathlib import Path
path = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, path)
from lib.config import load_config
from lib.logger import get_logger
logger = get_logger('character_portrait')
environ = os.environ.get
home = os.path.expanduser('~')
# ── 缓存目录 ──
CACHE_DIR = environ(
'AVE_CACHE_DIR',
os.path.join(home, 'workbuddy-agent-os/agent-local/tools/ave/cache/character_portrait'),
)
os.makedirs(CACHE_DIR, exist_ok=True)
# ── Kling API ──
KLING_BASE = 'https://api-beijing.klingai.com'
BASELINE_MODEL = 'kling-v3'
ANGLE_MODEL = 'kling-v1'
DEFAULT_SEED = 42
# Phase 2 参考链强度(逐级递增保持一致性)
REF_STRENGTH_CHAIN = (0.70, 0.75, 0.70)
# Phase 1: 基准图提示词模板
BASELINE_PROMPT_TEMPLATE = (
'一张超写实全身人像定妆照,必须正面朝向镜头,如证件照构图。'
'双脚自然分开与肩同宽,双手自然垂于身体两侧,面部正对相机,双耳左右对称可见。'
'严禁身体转向侧面或半侧面,不得有角度偏移。'
'{desc}'
'干净的浅灰色无缝影棚背景,左右双柔光箱对称布光,面部无阴影,'
'超高清8k,真实皮肤纹理,织物细节,发丝清晰可见,专业人像摄影质感。'
)
# Phase 2: 逐帧参考链定义
# 每帧: key(存储key), label(显示名), ref_source(参考图来源), strength(I2I强度), prompt(生成提示词)
ANGLE_CHAIN = [
{
'key': 'right_three_quarter',
'label': '右侧45°半侧面',
'ref_source': 'baseline',
'strength': 0.70,
'prompt': (
'与参考图完全相同的人物。全身镜头,四分之三右侧面,身体转向右侧约45度。'
'面部朝向镜头右前方,展现右侧面部特征。站姿放松自然。'
'保持完全一致的着装、配饰、发型和面部特征。'
'浅灰色影棚背景,超写实,专业人像布光,真实皮肤纹理,高清细节。'
),
},
{
'key': 'right_side',
'label': '纯右侧面',
'ref_source': 'right_three_quarter',
'strength': 0.75,
'prompt': (
'与参考图完全相同的人物。全身镜头,纯右侧面视角,人物面向右侧90度。'
'镜头从人物正右侧拍摄,展现鼻梁、嘴唇和下颌线的侧面轮廓。'
'着装与参考图完全一致,发型侧面层次清晰可见。'
'浅灰色影棚背景,超写实,轮廓清晰,高清,侧光勾勒面部轮廓。'
),
},
{
'key': 'back',
'label': '背面',
'ref_source': 'right_side',
'strength': 0.70,
'prompt': (
'与参考图完全相同的人物。全身镜头,人物背对镜头,从正后方拍摄。'
'展现后脑勺的发型层次感,肩背和腰线的服装贴合线条。'
'从背面可见服装背面的褶皱纹理和鞋子的后跟细节。'
'浅灰色影棚背景,超写实,真实皮肤纹理,高清8k。'
),
},
]
# ══════════════════════════════════════════════════════
# Kling 辅助工具
# ══════════════════════════════════════════════════════
def _get_kling_credentials() -> dict:
"""从 AVE 配置加载 Kling API 凭据。
Returns:
{"access_key": str, "secret_key": str}
Raises:
ValueError: 缺少配置
"""
cfg = load_config()
kling_cfg = cfg.get('kling', {})
ak = kling_cfg.get('access_key', '')
sk = kling_cfg.get('secret_key', '')
if not ak or not sk:
raise ValueError('缺少 Kling API 配置 (kling.access_key / kling.secret_key)')
return {'access_key': ak, 'secret_key': sk}
def _jwt_token(ak: str, sk: str) -> str:
"""生成 Kling JWT 认证 token。
Args:
ak: Access Key
sk: Secret Key
Returns:
JWT token 字符串
"""
try:
import jwt as pyjwt
except ImportError:
raise ImportError('需要 PyJWT 库: pip install pyjwt')
now = int(time.time())
return pyjwt.encode(
{'iss': ak, 'exp': now + 1800, 'nbf': now},
sk,
algorithm='HS256',
)
def _download(url: str, path: str):
"""下载文件。
Args:
url: 下载 URL
path: 保存路径
"""
import httpx
os.makedirs(os.path.dirname(path), exist_ok=True)
with httpx.stream('GET', url, timeout=600) as r:
r.raise_for_status()
with open(path, 'wb') as f:
for chunk in r.iter_bytes(8192):
f.write(chunk)
# ══════════════════════════════════════════════════════
# Kling T2I / I2I 核心
# ══════════════════════════════════════════════════════
def _t2i(
image_path: str,
prompt: str,
seed: int = 0,
model: str = 'kling-v3',
force: bool = False,
) -> dict:
"""Kling 文生图 (T2I)。
参数:
model: 默认 BASELINE_MODEL (kling-v3),可覆盖
force: 强制重新生成,跳过缓存
返回: {"path": str, "seed": int}
"""
# 检查缓存
if not force and os.path.exists(image_path):
seed_file = f'{image_path}.seed'
used_seed = seed
if os.path.exists(seed_file):
used_seed = int(open(seed_file).read().strip())
logger.info(f'T2I 缓存命中: {image_path}')
return {'path': image_path, 'seed': used_seed}
from lib.config import load_config
import httpx
import jwt
# 鉴权
creds = _get_kling_credentials()
ak = creds['access_key']
sk = creds['secret_key']
token = _jwt_token(ak, sk)
headers = {'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'}
# 请求
model_name = model or BASELINE_MODEL
payload = {
'model': model_name,
'prompt': prompt,
'aspect_ratio': '9:16',
'n': 1,
}
if seed:
payload['seed'] = seed
# 提交
for attempt in range(3):
try:
resp = httpx.post(
f'{KLING_BASE}/v1/images/generations',
headers=headers,
json=payload,
timeout=30,
)
if resp.status_code == 429:
wait = 15 * (attempt + 1)
logger.warning(f' 429 限流,等待 {wait}s...')
time.sleep(wait)
continue
resp.raise_for_status()
data = resp.json()
task_id = data.get('data', {}).get('task_id', '')
if not task_id:
raise RuntimeError(f'Kling T2I 提交失败: {data}')
break
except Exception as e:
if attempt == 2:
raise
time.sleep(5)
else:
raise RuntimeError('Kling T2I 提交失败(重试耗尽)')
# 轮询
for i in range(40):
time.sleep(5)
try:
q = httpx.get(
f'{KLING_BASE}/v1/images/generations/{task_id}',
headers=headers,
timeout=30,
)
q.raise_for_status()
status_data = q.json()['data']
task_status = status_data.get('task_status', '')
if task_status == 'succeed':
images = status_data.get('task_result', {}).get('images', [])
if not images:
raise RuntimeError('Kling T2I 成功但无图像')
img_url = images[0].get('url', '')
returned_seed = images[0].get('seed', seed)
# 下载
logger.info(f' T2I 完成,下载中...')
_download(img_url, image_path)
# 写种子文件
with open(f'{image_path}.seed', 'w') as f:
f.write(str(returned_seed))
logger.info(f' ✅ 基准图: {image_path} [seed={returned_seed}]')
return {'path': image_path, 'seed': returned_seed}
elif task_status == 'failed':
reason = status_data.get('fail_reason', '?')
raise RuntimeError(f'T2I 生成失败: {reason}')
except Exception as e:
if '轮询' in str(e):
logger.warning(f' {e}')
break
logger.warning(f' 轮询异常: {e}')
time.sleep(5)
raise TimeoutError('T2I 生成超时(200s)')
def _i2i(
output_path: str,
ref_image: str,
prompt: str,
seed: int = 0,
strength: float = 0.65,
model: str = 'kling-v1',
force: bool = False,
) -> dict:
"""Kling 图生图 (I2I),基于参考图生成。
参数:
strength: 参考强度,默认 0.65
model: 模型,默认 ANGLE_MODEL (kling-v1)
force: 强制重新生成
返回: {"path": str, "seed": int}
"""
# 检查缓存
if not force and os.path.exists(output_path):
seed_file = f'{output_path}.seed'
used_seed = seed
if os.path.exists(seed_file):
used_seed = int(open(seed_file).read().strip())
logger.info(f'I2I 缓存命中: {output_path}')
return {'path': output_path, 'seed': used_seed}
import httpx
import jwt
# 鉴权
creds = _get_kling_credentials()
ak = creds['access_key']
sk = creds['secret_key']
token = _jwt_token(ak, sk)
headers = {'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'}
# 读取参考图
if not os.path.exists(ref_image):
raise FileNotFoundError(f'I2I 参考图不存在: {ref_image}')
with open(ref_image, 'rb') as f:
img_b64 = base64.b64encode(f.read()).decode('utf-8')
model_name = model or ANGLE_MODEL
ref_strength = strength or 0.7
payload = {
'model': model_name,
'prompt': prompt,
'image': img_b64,
'strength': ref_strength,
'aspect_ratio': '9:16',
'n': 1,
}
if seed:
payload['seed'] = seed
# 提交
for attempt in range(3):
try:
resp = httpx.post(
f'{KLING_BASE}/v1/images/generations',
headers=headers,
json=payload,
timeout=30,
)
if resp.status_code == 429:
wait = 15 * (attempt + 1)
logger.warning(f' 429 限流,等待 {wait}s...')
time.sleep(wait)
continue
resp.raise_for_status()
data = resp.json()
task_id = data.get('data', {}).get('task_id', '')
if not task_id:
raise RuntimeError(f'Kling I2I 提交失败: {data}')
break
except Exception as e:
if attempt == 2:
raise
time.sleep(5)
else:
raise RuntimeError('Kling I2I 提交失败(重试耗尽)')
# 轮询
for i in range(40):
time.sleep(5)
try:
q = httpx.get(
f'{KLING_BASE}/v1/images/generations/{task_id}',
headers=headers,
timeout=30,
)
q.raise_for_status()
status_data = q.json()['data']
task_status = status_data.get('task_status', '')
if task_status == 'succeed':
images = status_data.get('task_result', {}).get('images', [])
if not images:
raise RuntimeError('Kling I2I 成功但无图像')
img_url = images[0].get('url', '')
returned_seed = images[0].get('seed', seed)
# 下载
logger.info(f' I2I 完成,下载中...')
_download(img_url, output_path)
# 写种子文件
with open(f'{output_path}.seed', 'w') as f:
f.write(str(returned_seed))
logger.info(f' ✅ 角度图: {output_path} [seed={returned_seed}]')
return {'path': output_path, 'seed': returned_seed}
elif task_status == 'failed':
reason = status_data.get('fail_reason', '?')
raise RuntimeError(f'I2I 生成失败: {reason}')
except Exception as e:
if '轮询' in str(e):
logger.warning(f' {e}')
break
logger.warning(f' 轮询异常: {e}')
time.sleep(5)
raise TimeoutError('I2I 生成超时(200s)')
# ══════════════════════════════════════════════════════
# 主流程
# ══════════════════════════════════════════════════════
def generate_baseline(
description: str,
char_name: str,
force: bool = False,
seed: int = 0,
ref_image: str = '',
) -> dict:
"""Phase 1: 生成正面全身基准图。
策略(v2.2):
1. 优先用真实照片 ref_image 做 I2I(真实照片→影棚风格,强度 0.85 保留面部)
2. 无参考照时回退 T2I (kling-v3)
参数:
ref_image: 真实参考照路径。有则走 I2I,无则走 T2I
返回:
{"baseline": "path/to/baseline.png", "seed": int, "method": "i2i|t2i"}
"""
output_dir = os.path.join(CACHE_DIR, char_name)
Path(output_dir).mkdir(parents=True, exist_ok=True)
baseline_path = os.path.join(output_dir, 'baseline.png')
seed_file = f'{baseline_path}.seed'
# 缓存检查
if not force and os.path.exists(baseline_path):
cached_seed = seed
if os.path.exists(seed_file):
cached_seed = int(open(seed_file).read().strip())
logger.info(f'基准图缓存命中: {baseline_path}')
return {'baseline': baseline_path, 'seed': cached_seed, 'method': 'cache'}
if ref_image and os.path.exists(ref_image):
# 策略 1: 真实照片 I2I → 影棚风格基准图
logger.info(f'=== Phase 1: 参考照 I2I 生成基准图 ({char_name}) ===')
logger.info(f' 参考照: {ref_image}')
prompt = (
'与参考照片完全相同的人物。全身镜头,正面站立直视镜头,如证件照构图。'
'浅灰色无缝影棚背景,左右双柔光箱对称布光,面部无阴影。'
'超高清8k,真实皮肤纹理,织物细节。'
)
result = _i2i(
baseline_path,
ref_image,
prompt,
seed=seed,
strength=0.85,
model=ANGLE_MODEL,
force=force,
)
return {
'baseline': result['path'],
'seed': result['seed'],
'method': 'i2i',
}
else:
# 策略 2: 纯 T2I
logger.info(f'=== Phase 1: T2I 生成基准图 ({char_name}) [kling-v3] ===')
prompt = BASELINE_PROMPT_TEMPLATE.format(desc=description)
result = _t2i(baseline_path, prompt, seed=seed, model=BASELINE_MODEL, force=force)
return {
'baseline': result['path'],
'seed': result['seed'],
'method': 't2i',
}
def generate_sequential_chain(
baseline_path: str,
char_name: str,
force: bool = False,
seed: int = 0,
) -> dict:
"""Phase 2: 逐帧参考链生成多角度定妆照。
v2.0 核心变更(对比 v1.0):
❌ v1.0 问题: 所有角度都用同一张 baseline 作 I2I 参考
→ 从正面直接跳到90°侧面,姿态变化太大,角色容易漂移
✅ v2.0 方案: 逐帧参考链
图1(正面) → 图2(右侧45°) → 图3(纯右侧面) → 图4(背面)
每张图都用上一张图作 I2I 参考,只做小幅姿态变化
返回:
{
"right_three_quarter": "path/to/angle_right_three_quarter.png",
"right_side": "path/to/angle_right_side.png",
"back": "path/to/angle_back.png",
"chain": [步骤详情],
"seed": int
}
"""
output_dir = os.path.join(CACHE_DIR, char_name, 'angles')
Path(output_dir).mkdir(parents=True, exist_ok=True)
results = {}
chain_log = []
final_seed = seed
# 构建参考映射: 每个步骤的参考图来源
ref_map = {'baseline': baseline_path}
for step in ANGLE_CHAIN:
ref_map[step['key']] = os.path.join(output_dir, f'angle_{step["key"]}.png')
for i, step in enumerate(ANGLE_CHAIN):
output_path = ref_map[step['key']]
ref_image = ref_map[step['ref_source']]
step_strength = step['strength']
step_prompt = step['prompt']
logger.info(f'==================================================')
logger.info(f'Phase 2 Step {i+1}/{len(ANGLE_CHAIN)}: {step["label"]}')
logger.info(f' 参考图: {step["ref_source"]} → {step["key"]}')
logger.info(f' 强度: {step_strength}')
try:
result = _i2i(
output_path,
ref_image,
step_prompt,
seed=seed,
strength=step_strength,
model=ANGLE_MODEL,
force=force,
)
results[step['key']] = result['path']
final_seed = result.get('seed', seed)
logger.info(f' ✅ {step["label"]} [seed={final_seed}]')
chain_log.append({
'step': i + 1,
'label': step['label'],
'ref_source': step['ref_source'],
'strength': step_strength,
'output': result['path'],
'seed': final_seed,
})
except Exception as e:
logger.warning(f' ❌ {step["label"]} 生成失败: {e}')
chain_log.append({
'step': i + 1,
'label': step['label'],
'ref_source': step['ref_source'],
'strength': step_strength,
'output': None,
'error': str(e),
})
# 写 chain meta
meta_path = os.path.join(output_dir, '_chain_meta.json')
meta = {
'character': char_name,
'method': 'sequential_reference_chain_v2',
'baseline': baseline_path,
'seed': final_seed,
'chain': chain_log,
}
Path(meta_path).write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding='utf-8')
return {
'right_three_quarter': results.get('right_three_quarter', ''),
'right_side': results.get('right_side', ''),
'back': results.get('back', ''),
'chain': chain_log,
'seed': final_seed,
}
def register_portraits(
char_name: str,
baseline_path: str,
angles: dict,
) -> bool:
"""Phase 3: 将定妆照注册到 CharacterRegistry。
参数:
char_name: 角色名
baseline_path: 基准图路径
angles: {"right_three_quarter": ..., "right_side": ..., "back": ...}
返回: 是否成功
"""
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from character_registry import CharacterRegistry
try:
registry_path = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
'character_registry',
'registry.yaml',
)
registry = CharacterRegistry()
# 注册 face_anchor
ref_images = {'face_anchor': [baseline_path], 'body': []}
# 注册 body 各角度
for key in ('right_three_quarter', 'right_side', 'back'):
if key in angles and angles[key]:
ref_images['body'].append({'path': angles[key], 'option': key, 'type': 'body'})
registry.update_reference_images(char_name, ref_images)
logger.info(f'✅ 已注册定妆照到 CharacterRegistry: {char_name}')
return True
except Exception as e:
logger.warning(f'注册到 CharacterRegistry 失败: {e}')
return False
def generate_and_register_portraits(
character_name: str,
description: str,
force: bool = False,
seed: int = 0,
ref_image: str = '',
) -> dict:
"""全流程: 基准图 → 多角度 → 注册。
参数:
character_name: 角色名(与 registry.yaml 一致)
description: 角色描述(自然语言)
force: 强制重新生成
seed: 固定种子
ref_image: 真实参考照路径。有则 I2I 生成基准图,无则 T2I
返回:
{
"character": "角色名",
"baseline": "path/to/baseline.png",
"angles": {"right_three_quarter": "...", "right_side": "...", "back": "..."},
"registry_updated": True/False,
"seed": 42,
}
"""
result = {
'character': character_name,
'baseline': '',
'angles': {},
'registry_updated': False,
'seed': seed,
}
logger.info(f'==================================================')
logger.info(f'🎯 定妆照全流程: {character_name}')
# Phase 1: 基准图
try:
logger.info('📷 Phase 1/2: 生成基准图(正面全身定妆照)...')
baseline_result = generate_baseline(
description=description,
char_name=character_name,
force=force,
seed=seed,
ref_image=ref_image,
)
baseline_path = baseline_result.get('baseline', '')
used_seed = baseline_result.get('seed', seed)
result['baseline'] = baseline_path
result['seed'] = used_seed
logger.info(f' 基准图: {baseline_path}')
logger.info(f' seed: {used_seed}')
except Exception as e:
logger.error(f' ❌ Phase 1 失败: {e}')
result['error'] = f'Phase 1 (基准图) 失败: {str(e)}'
return result
# Phase 2: 逐帧参考链
try:
logger.info('📸 Phase 2/2: 逐帧参考链生成多角度定妆照...')
logger.info(' 参考链: 正面 → 右侧45°半侧面 → 纯右侧面 → 背面')
logger.info(' 每张以上一张为 I2I 参考,强度 0.70→0.75→0.70')
angles = generate_sequential_chain(
baseline_path=baseline_path,
char_name=character_name,
force=force,
seed=used_seed,
)
result['angles'] = {
'right_three_quarter': angles.get('right_three_quarter', ''),
'right_side': angles.get('right_side', ''),
'back': angles.get('back', ''),
}
logger.info(f' 右侧45°半侧面: {result["angles"]["right_three_quarter"][:50] if result["angles"]["right_three_quarter"] else "失败"}')
logger.info(f' 纯右侧面: {result["angles"]["right_side"][:50] if result["angles"]["right_side"] else "失败"}')
logger.info(f' 背面: {result["angles"]["back"][:50] if result["angles"]["back"] else "失败"}')
except Exception as e:
logger.error(f' ❌ Phase 2 失败: {e}')
result['warning'] = f'Phase 2 (多角度) 部分失败: {str(e)}'
# Phase 3: 注册
try:
logger.info('📋 Phase 3/2: 注册到 CharacterRegistry...')
reg_ok = register_portraits(
char_name=character_name,
baseline_path=baseline_path,
angles=result['angles'],
)
result['registry_updated'] = reg_ok
logger.info(f' {"✅ 注册成功" if reg_ok else "⚠️ 注册失败"}')
except Exception as e:
logger.warning(f' 注册异常: {e}')
result['registry_updated'] = False
logger.info(f'✅ 定妆照生成完成: {character_name}')
return result
# ══════════════════════════════════════════════════════
# CLI 入口
# ══════════════════════════════════════════════════════
def cli(args: list):
"""CLI 入口。"""
import argparse
parser = argparse.ArgumentParser(description='两阶段定妆照生成(观鱼方案)')
parser.add_argument('--character', required=True, help='角色名')
parser.add_argument('--desc', type=str, default='', help='角色描述(自然语言)')
parser.add_argument('--force', action='store_true', default=False, help='强制重新生成')
parser.add_argument('--seed', type=int, default=0, help='固定种子')
parser.add_argument('--desc-file', type=str, default='', help='从文件读取角色描述')
parser.add_argument('--ref', type=str, default='', help='真实参考照路径(推荐,代替T2I)')
parsed = parser.parse_args(args)
desc = parsed.desc
if parsed.desc_file:
desc_file = Path(parsed.desc_file)
if desc_file.exists():
desc = desc_file.read_text(encoding='utf-8').strip()
result = generate_and_register_portraits(
character_name=parsed.character,
description=desc,
force=parsed.force,
seed=parsed.seed,
ref_image=parsed.ref,
)
print()
print('==================================================')
print('✅ 定妆照生成结果:')
print(f' 基准图: {result.get("baseline", "失败")}')
if result.get('angles'):
for key, label in [('right_three_quarter', '右侧45°半侧面'), ('right_side', '纯右侧面'), ('back', '背面')]:
print(f' {label}: {result["angles"].get(key, "失败")}')
print(f' seed: {result.get("seed", "N/A")}')
print(f' 已注册: {"✅" if result.get("registry_updated") else "❌"}')
if result.get('error'):
print(f' 错误: {result["error"]}')
if result.get('warning'):
print(f' 警告: {result["warning"]}')
if __name__ == '__main__':
import sys
cli(sys.argv[1:])
@@ -0,0 +1,318 @@
"""
角色注册中心 (Character Registry) — 漫剧视频工厂
功能:
- 注册/管理/切换主角
- 存储角色描述、外观、性格、音色、画风
- 生成角色描述块 (character block),供角色适配器和视觉生成模块注入
- 支持多角色切换
用法:
from character_registry import CharacterRegistry
registry = CharacterRegistry()
char = registry.get_active_character()
block = char.build_prompt_block(style="manhua", expression="微笑", scene="咖啡馆")
"""
import os
import json
import yaml
from pathlib import Path
from typing import Optional
# ── 默认路径 ──
SCRIPTS_DIR = Path(__file__).resolve().parent
AVE_ROOT = SCRIPTS_DIR.parent
CACHE_DIR = Path(os.environ.get(
"AVE_CACHE_DIR",
str(Path.home() / "workbuddy-agent-os/agent-local/tools/ave/cache/character_registry")
))
CACHE_DIR.mkdir(parents=True, exist_ok=True)
REGISTRY_FILE = SCRIPTS_DIR / "registry.yaml"
CHARACTERS_DIR = CACHE_DIR / "characters"
CHARACTERS_DIR.mkdir(parents=True, exist_ok=True)
# ═══════════════════════════════════════════════════════════
# 角色类
# ═══════════════════════════════════════════════════════════
class Character:
"""单个角色的完整定义"""
def __init__(self, data: dict):
self.name: str = data.get("name", "未命名")
self.description: str = data.get("description", "")
self.appearance: list[str] = data.get("appearance", [])
self.personality: str = data.get("personality", "")
self.voice_style: str = data.get("voice_style", "默认")
self.art_style: str = data.get("art_style", "")
self.default_bgm_style: str = data.get("default_bgm_style", "治愈")
self.preferred_bgm_tags: list[str] = data.get("preferred_bgm_tags", [])
self.reference_images: dict = data.get("reference_images", {})
self.prompt_template: dict = data.get("prompt_template", {})
self._raw = data
# ── 角色描述块生成 ──
def build_prompt_block(self, style: str = "manhua", expression: str = "中性",
scene: str = "", camera_angle: str = "中景") -> str:
"""
构建角色描述块,用于注入视觉生成 prompt
参数:
style: "manhua" | "realistic"
expression: 角色当前表情/情绪
scene: 场景描述
camera_angle: 镜头角度
返回:
格式化的角色描述块字符串
"""
app_desc = ",".join(self.appearance[:3]) # 取前3个外观特征
char_desc = f"{self.name},{app_desc}"
template = self.prompt_template.get(style, "")
if template:
return template.format(
character_desc=char_desc,
scene_desc=scene,
expression=expression,
camera_angle=camera_angle,
)
# 默认模板
if style == "manhua":
return (
f"[角色] {char_desc}\n"
f"[场景] {scene}\n"
f"[表情] {expression}\n"
f"[画风] {self.art_style}\n"
f"[镜头] {camera_angle}"
)
else:
return (
f"[角色] {char_desc}\n"
f"[场景] {scene}\n"
f"[表情] {expression}\n"
f"[画风] 电影级摄影,写实风格\n"
f"[镜头] {camera_angle}"
)
def to_storyboard_block(self) -> dict:
"""生成角色描述块 JSON(供分镜引用)"""
return {
"character_name": self.name,
"appearance": self.appearance,
"personality": self.personality,
"voice_style": self.voice_style,
"art_style": self.art_style,
"reference_images": self.reference_images,
"default_bgm_style": self.default_bgm_style,
}
def to_dict(self) -> dict:
return self._raw
def __repr__(self) -> str:
return f"<Character: {self.name}>"
# ═══════════════════════════════════════════════════════════
# 角色注册中心
# ═══════════════════════════════════════════════════════════
class CharacterRegistry:
"""角色注册中心 — 管理所有已注册角色"""
def __init__(self, registry_path: Optional[str] = None):
self._registry_path = Path(registry_path or REGISTRY_FILE)
self._characters: dict[str, Character] = {}
self._active_name: str = ""
self._load()
# ── 加载/刷新 ──
def _load(self):
"""从 registry.yaml 加载角色"""
if not self._registry_path.exists():
raise FileNotFoundError(f"角色注册文件不存在: {self._registry_path}")
with open(self._registry_path, encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
self._active_name = data.get("active_character", "")
chars = data.get("characters", {})
for name, char_data in chars.items():
char_data["name"] = name
self._characters[name] = Character(char_data)
if not self._active_name and self._characters:
self._active_name = list(self._characters.keys())[0]
def reload(self):
"""重新加载注册文件"""
self._load()
# ── 查询 ──
def get_active_character(self) -> Character:
"""获取当前激活的角色"""
if self._active_name in self._characters:
return self._characters[self._active_name]
raise KeyError(f"未找到激活角色: {self._active_name}")
def get_character(self, name: str) -> Character:
"""按名字获取角色"""
if name in self._characters:
return self._characters[name]
raise KeyError(f"未找到角色: {name}")
def list_characters(self) -> list[str]:
"""列出所有已注册角色名"""
return list(self._characters.keys())
def get_active_name(self) -> str:
return self._active_name
# ── 参考图管理 ──
def set_reference_image(self, name: str, key: str, path: str) -> bool:
"""
设置角色的单张参考图路径
参数:
name: 角色名
key: 参考图类型 (portrait / grid / full_body / expressions.happy 等)
path: 图片文件路径
使用嵌套 key 的示例: set_reference_image("小漫", "expressions.happy", "...")
"""
char = self.get_character(name)
if "." in key:
# 嵌套 key: "expressions.happy" → reference_images["expressions"]["happy"]
parts = key.split(".", 1)
if parts[0] not in char.reference_images:
char.reference_images[parts[0]] = {}
char.reference_images[parts[0]][parts[1]] = path
else:
char.reference_images[key] = path
# 同步到 _raw 以持久化
char._raw["reference_images"] = char.reference_images
self._save()
return True
def update_reference_images(self, name: str, images: dict) -> bool:
"""
批量更新角色的参考图路径
参数:
name: 角色名
images: {
"portrait": "path/to/portrait.png",
"grid": "path/to/grid.png",
"expressions": {"happy": "...", "sad": "...", "angry": "..."}
}
"""
char = self.get_character(name)
# 深度合并
for key, value in images.items():
if isinstance(value, dict) and key in char.reference_images and isinstance(char.reference_images[key], dict):
char.reference_images[key].update(value)
else:
char.reference_images[key] = value
char._raw["reference_images"] = char.reference_images
self._save()
return True
def get_reference_images(self, name: str) -> dict:
"""获取角色的所有参考图路径"""
char = self.get_character(name)
return dict(char.reference_images)
def clear_reference_images(self, name: str) -> bool:
"""清空角色的参考图路径"""
char = self.get_character(name)
char.reference_images = {}
char._raw["reference_images"] = {}
self._save()
return True
# ── 切换/注册 ──
def switch_to(self, name: str) -> bool:
"""切换到指定角色"""
if name not in self._characters:
raise KeyError(f"未找到角色: {name}")
self._active_name = name
self._save()
return True
def register(self, character_data: dict) -> Character:
"""注册新角色"""
name = character_data.get("name", "")
if not name:
raise ValueError("角色必须包含 name 字段")
self._characters[name] = Character(character_data)
if not self._active_name:
self._active_name = name
self._save()
return self._characters[name]
def delete(self, name: str) -> bool:
"""删除角色"""
if name not in self._characters:
return False
del self._characters[name]
if self._active_name == name:
self._active_name = list(self._characters.keys())[0] if self._characters else ""
self._save()
return True
# ── 持久化 ──
def _save(self):
"""保存当前状态到 registry.yaml"""
chars_dict = {}
for name, char in self._characters.items():
chars_dict[name] = char.to_dict()
data = {
"active_character": self._active_name,
"characters": chars_dict,
}
with open(self._registry_path, "w", encoding="utf-8") as f:
yaml.dump(data, f, allow_unicode=True, default_flow_style=False, sort_keys=False)
def __len__(self) -> int:
return len(self._characters)
def __repr__(self) -> str:
return f"<CharacterRegistry: {self._active_name} active, {len(self)} total>"
# ═══════════════════════════════════════════════════════════
# 独立使用入口
# ═══════════════════════════════════════════════════════════
if __name__ == "__main__":
registry = CharacterRegistry()
print(f"角色注册中心: {registry}")
print(f"活跃角色: {registry.get_active_name()}")
char = registry.get_active_character()
print(f"\n角色详情:")
print(f" 名字: {char.name}")
print(f" 描述: {char.description}")
print(f" 外观: {char.appearance}")
print(f" 性格: {char.personality}")
print(f" 音色: {char.voice_style}")
print(f" 画风: {char.art_style}")
print(f"\n角色描述块 (漫剧):")
print(char.build_prompt_block(style="manhua", expression="微笑", scene="清晨的咖啡馆"))
@@ -0,0 +1,700 @@
active_character: 阿远
characters:
小漫:
description: 活泼可爱的少女漫画家,短发,大眼睛,总是带着画板和笔
appearance:
- 短发,齐刘海
- 大眼睛,瞳孔呈深棕色
- 常穿白色连衣裙配红色蝴蝶结
- 身高约160cm,偏瘦
personality: 开朗、好奇、偶尔冒失
voice_style: 活泼少女音
art_style: 日系漫画风格,明亮色调,线条简洁
default_bgm_style: 治愈
preferred_bgm_tags:
- 治愈
- 温暖
- 轻快
reference_images:
full_body: ''
portrait: ''
expressions:
happy: ''
sad: ''
angry: ''
prompt_template:
manhua: 日系漫画风格,{character_desc},{scene_desc},表情{expression},{camera_angle}
realistic: '{character_desc},{scene_desc},表情{expression},电影级摄影,{camera_angle}'
name: 小漫
阿远:
name: 阿远
description: 一位25岁的城市程序员,黑短发,戴细框金属眼镜,身高178cm偏瘦,常穿浅灰色卫衣和深蓝色牛仔裤,性格温和内敛有点宅,但在熟悉的人面前也会露出温暖的笑容
appearance:
- 黑短发,三七分
- 细框金属眼镜,单眼皮
- 身高约178cm,偏瘦体型
- 常穿浅灰色连帽卫衣
- 戴一块简约的黑色电子手表
personality: 温和、内敛、偶尔倔强、专注、在熟悉的人面前温暖
voice_style: 温柔青年音
art_style: 日系清新漫画风格,柔和色调,线条干净
default_bgm_style: 温暖
preferred_bgm_tags:
- 温暖
- 安静
- 治愈
- 轻快
prompt_template:
manhua: 日系清新漫画风格,{character_desc},{scene_desc},表情{expression},{camera_angle}
realistic: '{character_desc},{scene_desc},表情{expression},电影级摄影风格,柔和自然光线,{camera_angle}'
reference_images:
grid: /Users/7kecheng/workbuddy-agent-os/agent-local/tools/ave/cache/character_sheet/阿远/阿远_grid.png
portrait: /Users/7kecheng/workbuddy-agent-os/agent-local/tools/ave/cache/character_sheet/阿远/阿远_grid_face_neutral.png
expressions:
neutral: /Users/7kecheng/workbuddy-agent-os/agent-local/tools/ave/cache/character_sheet/阿远/阿远_grid_face_neutral.png
smile: /Users/7kecheng/workbuddy-agent-os/agent-local/tools/ave/cache/character_sheet/阿远/阿远_grid_face_smile.png
angry: /Users/7kecheng/workbuddy-agent-os/agent-local/tools/ave/cache/character_sheet/阿远/阿远_grid_face_angry.png
ghai_sports:
name: ghai_sports
base: 一位中国男性,约42岁,体型健壮肩宽,黑色短发利落,国字脸,单眼皮眼尾微垂,鼻梁挺直鼻翼略宽,嘴唇厚度中等嘴角微收,神态沉稳内敛,肤色健康自然
description: 一位中国男性,约42岁,体型健壮肩宽,黑色短发利落,国字脸单眼皮,神态沉稳内敛,肤色健康自然,身穿深灰色运动夹克配白色速干内衬,右耳戴着蓝色无线运动耳机。
appearance:
- 黑色短发,略长顶部,两侧收短,整洁干练
- 圆润饱满的国字脸,单眼皮
- 体型健壮,肩宽,稳重感强
- 深灰色拉链运动夹克,白色圆领速干内衬
- 右耳蓝色无线运动耳机
personality: 沉稳、专注、坚韧
voice_style: 成熟稳重男声
art_style: 写实摄影风格,运动场景,自然光线
default_bgm_style: 激励
preferred_bgm_tags:
- 激励
- 热血
- 节奏感
- 运动
sheet_mode: sport
seed: 42
reference_images:
real_photo_portrait: /Users/7kecheng/workbuddy-agent-os/agent-sync/05_tools/09_ave/scripts/character_registry/assets/ghai_sports/reference_portrait.jpg
real_photo_fullbody: /Users/7kecheng/workbuddy-agent-os/agent-sync/05_tools/09_ave/scripts/character_registry/assets/ghai_sports/reference_fullbody.png
face_anchor: /Users/7kecheng/workbuddy-agent-os/agent-local/tools/ave/cache/character_sheet/ghai_sports/face_anchor.png
grid: /Users/7kecheng/workbuddy-agent-os/agent-local/tools/ave/cache/character_sheet/ghai_sports/ghai_sports_grid.png
portrait: /Users/7kecheng/workbuddy-agent-os/agent-local/tools/ave/cache/character_sheet/ghai_sports/ghai_sports_grid_face_front.png
expressions:
front: /Users/7kecheng/workbuddy-agent-os/agent-local/tools/ave/cache/character_sheet/ghai_sports/ghai_sports_grid_face_front.png
3qL: /Users/7kecheng/workbuddy-agent-os/agent-local/tools/ave/cache/character_sheet/ghai_sports/ghai_sports_grid_face_3qL.png
profile: /Users/7kecheng/workbuddy-agent-os/agent-local/tools/ave/cache/character_sheet/ghai_sports/ghai_sports_grid_face_profile.png
3qR: /Users/7kecheng/workbuddy-agent-os/agent-local/tools/ave/cache/character_sheet/ghai_sports/ghai_sports_grid_face_3qR.png
focus: /Users/7kecheng/workbuddy-agent-os/agent-local/tools/ave/cache/character_sheet/ghai_sports/ghai_sports_grid_face_focus.png
intense: /Users/7kecheng/workbuddy-agent-os/agent-local/tools/ave/cache/character_sheet/ghai_sports/ghai_sports_grid_face_intense.png
body:
front: /Users/7kecheng/workbuddy-agent-os/agent-local/tools/ave/cache/character_sheet/ghai_sports/ghai_sports_grid_body_front.png
side: /Users/7kecheng/workbuddy-agent-os/agent-local/tools/ave/cache/character_sheet/ghai_sports/ghai_sports_grid_body_side.png
seed: null
outfit_presets:
running:
outfit: 透气速干T恤和跑步短裤
accessories: 右耳戴着蓝色无线运动耳机
prompt_suffix: 晨跑场景,动态模糊,清晨自然光线
gym:
outfit: 深色运动背心和训练短裤
accessories: ''
prompt_suffix: 健身房场景,力量训练,金属器械背景,侧光
basketball:
outfit: 篮球运动背心和短裤
accessories: 运动护腕
prompt_suffix: 街头篮球场,水泥地,傍晚光线
outdoor_fitness:
outfit: 白色健身T恤和运动长裤
accessories: ''
prompt_suffix: 户外健身,单杠器械,城市公园,午后阳光
prompt_template:
realistic: '{base},{outfit},{scene_desc},{camera_angle},写实摄影风格,自然光线'
sport: '{base},{outfit},{scene_desc},运动摄影风格,高速快门,{camera_angle}'
裔年轻:
name: 裔年轻
description: 中景镜头,一位气质干净清澈的亚裔年轻男性,长相温润正派,五官端正立体,眼神真诚温暖带笑。身着燕麦色羊绒衫(袖口微微挽起)外搭深藏蓝亚麻外套,直身微侧,姿态挺拔舒展。背景是雨后湿润的江南古巷,斑驳青砖墙,脚下石板路反着水光。他一手拎着一袋用褐色纸袋包着、微微冒着热气的桂花糕,低头看向纸袋,嘴角带着温柔的笑意。
高清细节,暖色调,湿漉漉的地面映照天色,整体呈现胶片般柔和细腻的质感。
base: 中景镜头,一位气质干净清澈的亚裔年轻男性,长相温润正派,五官端正立体,眼神真诚温暖带笑。身着燕麦色羊绒衫外搭深藏蓝亚麻外套,直身微侧,姿态挺拔舒展。背景是雨后湿润的江南古巷,斑驳青砖墙,脚下石板路反着水光。他一手拎着一袋用褐色纸袋包着、微微冒着热气的桂花糕,低头看向纸袋,嘴角带着温柔的笑意。
高清细节,暖色调,湿漉漉的地面映照天色,整体呈现胶片般柔和细腻的质感
personality: 气质干净清澈的亚裔年轻男性
voice_style: 默认
art_style: 写实摄影风格
appearance:
- 中景镜头
- 一位气质干净清澈的亚裔年轻男性
- 长相温润正派
- 五官端正立体
- 眼神真诚温暖带笑
face:
shape: ''
eyes: ''
nose: ''
lips: 嘴角
hair: ''
skin: ''
facial_hair: ''
glasses: ''
distinctive: ''
body:
build: ''
height: ''
posture: 挺拔
age_group: ''
clothing:
top: 身着燕麦色羊绒衫(袖口微微挽起)外搭深藏蓝亚麻外套
inner: 背景是雨后湿润的江南古巷
bottom: ''
shoes: ''
style: ''
prompt_template:
realistic: '{base},{scene_desc},{camera_angle},写实摄影风格,自然光线'
preferred_bgm_tags:
- 温暖
reference_images:
face_anchor: /Users/7kecheng/workbuddy-agent-os/agent-local/tools/ave/cache/character_portrait/裔年轻/front_face.png
baseline: /Users/7kecheng/workbuddy-agent-os/agent-local/tools/ave/cache/character_portrait/裔年轻/front_face.png
body:
front: /Users/7kecheng/workbuddy-agent-os/agent-local/tools/ave/cache/character_portrait/裔年轻/multi_angle_front.png
side: /Users/7kecheng/workbuddy-agent-os/agent-local/tools/ave/cache/character_portrait/裔年轻/multi_angle_side.png
right_three_quarter: /Users/7kecheng/workbuddy-agent-os/agent-local/tools/ave/cache/character_portrait/裔年轻/multi_angle_right_three_quarter.png
right_side: /Users/7kecheng/workbuddy-agent-os/agent-local/tools/ave/cache/character_portrait/裔年轻/multi_angle_right_side.png
back: /Users/7kecheng/workbuddy-agent-os/agent-local/tools/ave/cache/character_portrait/裔年轻/multi_angle_back.png
expressions:
neutral: /Users/7kecheng/workbuddy-agent-os/agent-local/tools/ave/cache/character_portrait/裔年轻/expression_neutral.png
smile: /Users/7kecheng/workbuddy-agent-os/agent-local/tools/ave/cache/character_portrait/裔年轻/expression_smile.png
focused: /Users/7kecheng/workbuddy-agent-os/agent-local/tools/ave/cache/character_portrait/裔年轻/expression_focused.png
laugh: /Users/7kecheng/workbuddy-agent-os/agent-local/tools/ave/cache/character_portrait/裔年轻/expression_laugh.png
serious: /Users/7kecheng/workbuddy-agent-os/agent-local/tools/ave/cache/character_portrait/裔年轻/expression_serious.png
夺:
name: 夺
description: 奇才
base: 奇才
personality: 默认
voice_style: 默认
art_style: 写实摄影风格
face:
shape: ''
eyes: ''
nose: ''
lips: ''
hair: ''
skin: ''
facial_hair: ''
glasses: ''
distinctive: ''
body:
build: ''
height: ''
posture: ''
age_group: ''
clothing:
top: ''
inner: ''
bottom: ''
shoes: ''
style: ''
中国:
name: 中国
description: '【身份】女性、青年、中国、姐三
【面部轮廓】适中自然、适中
【五官细节】深褐、高挺、适中、自然闭合
【头发&皮肤】短发、黑色、健康小麦、自然肌理、红润健康、素颜
【体型姿态】标准、挺拔
【神态&场景】柔光箱均匀布光、中焦85mm、纯灰色专业背景'
base: '【身份】女性、青年、中国、姐三
【面部轮廓】适中自然、适中
【五官细节】深褐、高挺、适中、自然闭合
【头发&皮肤】短发、黑色、健康小麦、自然肌理、红润健康、素颜
【体型姿态】标准、挺拔
【神态&场景】柔光箱均匀布光、中焦85mm、纯灰色专业背景'
personality: 默认
voice_style: 默认
art_style: 写实摄影风格
appearance:
- 体型:标准
face:
shape: ''
eyes: ''
nose: ''
lips: ''
hair: '【身份】女性、青年、中国、姐三
【面部轮廓】适中自然、适中
【五官细节】深褐、高挺、适中、自然闭合
【头发&皮肤】短发、黑色、健康小麦、自然肌理、红润健康、素颜
【体型姿态】标准、挺拔
【神态&场景】柔光箱均匀布光、中焦85mm、纯灰色专业背景'
skin: '健康小麦、自然肌理、红润健康、素颜
【体型姿态】标准、挺拔
【神态&场景】柔光箱均匀布光、中焦85mm、纯灰色专业背景'
facial_hair: ''
glasses: ''
distinctive: ''
body:
build: 标准
height: ''
posture: 自然
age_group: ''
clothing:
top: ''
inner: '【身份】女性、青年、中国、姐三
【面部轮廓】适中自然、适中
【五官细节】深褐、高挺、适中、自然闭合
【头发&皮肤】短发、黑色、健康小麦、自然肌理、红润健康、素颜
【体型姿态】标准、挺拔
【神态&场景】柔光箱均匀布光、中焦85mm、纯灰色专业背景'
bottom: '【身份】女性、青年、中国、姐三
【面部轮廓】适中自然、适中
【五官细节】深褐、高挺、适中、自然闭合
【头发&皮肤】短发、黑色、健康小麦、自然肌理、红润健康、素颜
【体型姿态】标准、挺拔
【神态&场景】柔光箱均匀布光、中焦85mm、纯灰色专业背景'
shoes: ''
style: ''
测试:
name: 测试
description: 测试角色
base: 测试角色
personality: 默认
voice_style: 默认
art_style: 写实摄影风格
face:
shape: ''
eyes: ''
nose: ''
lips: ''
hair: ''
skin: ''
facial_hair: ''
glasses: ''
distinctive: ''
body:
build: ''
height: ''
posture: ''
age_group: ''
clothing:
top: ''
inner: ''
bottom: ''
shoes: ''
style: ''
测试复杂:
name: 测试复杂
description: '【身份】男性、青年、中国
【面部轮廓】鹅蛋脸、清晰分明
【五官细节】丹凤眼、深褐、高挺
【头发&皮肤】短发、黑色、健康小麦'
base: '【身份】男性、青年、中国
【面部轮廓】鹅蛋脸、清晰分明
【五官细节】丹凤眼、深褐、高挺
【头发&皮肤】短发、黑色、健康小麦'
personality: 默认
voice_style: 默认
art_style: 写实摄影风格
face:
shape: ''
eyes: ''
nose: ''
lips: ''
hair: '【身份】男性、青年、中国
【面部轮廓】鹅蛋脸、清晰分明
【五官细节】丹凤眼、深褐、高挺
【头发&皮肤】短发、黑色、健康小麦'
skin: 健康小麦
facial_hair: ''
glasses: ''
distinctive: ''
body:
build: ''
height: ''
posture: ''
age_group: ''
clothing:
top: ''
inner: ''
bottom: '【身份】男性、青年、中国
【面部轮廓】鹅蛋脸、清晰分明
【五官细节】丹凤眼、深褐、高挺
【头发&皮肤】短发、黑色、健康小麦'
shoes: ''
style: ''
测试角色:
name: 测试角色
description: '【身份】男性、青年、中国
【面部轮廓】鹅蛋脸
【五官细节】丹凤眼、深褐
【头发&皮肤】短发、黑色
【体型姿态】中等、标准匀称
【服装配饰】深灰色夹克'
base: '【身份】男性、青年、中国
【面部轮廓】鹅蛋脸
【五官细节】丹凤眼、深褐
【头发&皮肤】短发、黑色
【体型姿态】中等、标准匀称
【服装配饰】深灰色夹克'
personality: 默认
voice_style: 默认
art_style: 写实摄影风格
face:
shape: ''
eyes: ''
nose: ''
lips: ''
hair: '【身份】男性、青年、中国
【面部轮廓】鹅蛋脸
【五官细节】丹凤眼、深褐
【头发&皮肤】短发、黑色
【体型姿态】中等、标准匀称
【服装配饰】深灰色夹克'
skin: ''
facial_hair: ''
glasses: ''
distinctive: ''
body:
build: 标准
height: ''
posture: ''
age_group: ''
clothing:
top: '【身份】男性、青年、中国
【面部轮廓】鹅蛋脸
【五官细节】丹凤眼、深褐
【头发&皮肤】短发、黑色
【体型姿态】中等、标准匀称
【服装配饰】深灰色夹克'
inner: ''
bottom: '【身份】男性、青年、中国
【面部轮廓】鹅蛋脸
【五官细节】丹凤眼、深褐
【头发&皮肤】短发、黑色
【体型姿态】中等、标准匀称
【服装配饰】深灰色夹克'
shoes: ''
style: ''
appearance:
- 体型:标准
- 服装:深灰色夹克
测试角色2:
name: 测试角色2
description: 测试方向描述
base: 测试方向描述
personality: 默认
voice_style: 默认
art_style: 写实摄影风格
face:
shape: ''
eyes: ''
nose: ''
lips: ''
hair: ''
skin: ''
facial_hair: ''
glasses: ''
distinctive: ''
body:
build: ''
height: ''
posture: ''
age_group: ''
clothing:
top: ''
inner: ''
bottom: ''
shoes: ''
style: ''
台湾:
name: 台湾
description: '【身份】青年、中国、少女
【面部轮廓】适中自然、适中
【五官细节】深褐、高挺、适中、自然闭合
【头发&皮肤】短发、黑色、健康小麦、自然肌理、红润健康、素颜
【体型姿态】标准、挺拔
【神态&场景】柔光箱均匀布光、中焦85mm、纯灰色专业背景'
base: '【身份】青年、中国、少女
【面部轮廓】适中自然、适中
【五官细节】深褐、高挺、适中、自然闭合
【头发&皮肤】短发、黑色、健康小麦、自然肌理、红润健康、素颜
【体型姿态】标准、挺拔
【神态&场景】柔光箱均匀布光、中焦85mm、纯灰色专业背景'
personality: 默认
voice_style: 默认
art_style: 写实摄影风格
appearance:
- 体型:标准
face:
shape: ''
eyes: ''
nose: ''
lips: ''
hair: '【身份】青年、中国、少女
【面部轮廓】适中自然、适中
【五官细节】深褐、高挺、适中、自然闭合
【头发&皮肤】短发、黑色、健康小麦、自然肌理、红润健康、素颜
【体型姿态】标准、挺拔
【神态&场景】柔光箱均匀布光、中焦85mm、纯灰色专业背景'
skin: '健康小麦、自然肌理、红润健康、素颜
【体型姿态】标准、挺拔
【神态&场景】柔光箱均匀布光、中焦85mm、纯灰色专业背景'
facial_hair: ''
glasses: ''
distinctive: ''
body:
build: 标准
height: ''
posture: 自然
age_group: ''
clothing:
top: ''
inner: '【身份】青年、中国、少女
【面部轮廓】适中自然、适中
【五官细节】深褐、高挺、适中、自然闭合
【头发&皮肤】短发、黑色、健康小麦、自然肌理、红润健康、素颜
【体型姿态】标准、挺拔
【神态&场景】柔光箱均匀布光、中焦85mm、纯灰色专业背景'
bottom: '【身份】青年、中国、少女
【面部轮廓】适中自然、适中
【五官细节】深褐、高挺、适中、自然闭合
【头发&皮肤】短发、黑色、健康小麦、自然肌理、红润健康、素颜
【体型姿态】标准、挺拔
【神态&场景】柔光箱均匀布光、中焦85mm、纯灰色专业背景'
shoes: ''
style: ''
test:
name: test
description: '【身份】男性
【面部】标准'
base: '【身份】男性
【面部】标准'
personality: 默认
voice_style: 默认
art_style: 写实摄影风格
appearance:
- 体型:标准
face:
shape: ''
eyes: ''
nose: ''
lips: ''
hair: ''
skin: ''
facial_hair: ''
glasses: ''
distinctive: ''
body:
build: 标准
height: ''
posture: ''
age_group: ''
clothing:
top: ''
inner: ''
bottom: ''
shoes: ''
style: ''
顺:
name: 顺
description: '【身份】女性、青年、中国、止
【面部轮廓】适中自然、适中
【五官细节】深褐、高挺、适中、自然闭合
【头发&皮肤】短发、黑色、健康小麦、自然肌理、红润健康、素颜
【体型姿态】标准、挺拔
【神态&场景】柔光箱均匀布光、中焦85mm、纯灰色专业背景'
base: '【身份】女性、青年、中国、止
【面部轮廓】适中自然、适中
【五官细节】深褐、高挺、适中、自然闭合
【头发&皮肤】短发、黑色、健康小麦、自然肌理、红润健康、素颜
【体型姿态】标准、挺拔
【神态&场景】柔光箱均匀布光、中焦85mm、纯灰色专业背景'
personality: 默认
voice_style: 默认
art_style: 写实摄影风格
appearance:
- 体型:标准
face:
shape: ''
eyes: ''
nose: ''
lips: ''
hair: '【身份】女性、青年、中国、止
【面部轮廓】适中自然、适中
【五官细节】深褐、高挺、适中、自然闭合
【头发&皮肤】短发、黑色、健康小麦、自然肌理、红润健康、素颜
【体型姿态】标准、挺拔
【神态&场景】柔光箱均匀布光、中焦85mm、纯灰色专业背景'
skin: '健康小麦、自然肌理、红润健康、素颜
【体型姿态】标准、挺拔
【神态&场景】柔光箱均匀布光、中焦85mm、纯灰色专业背景'
facial_hair: ''
glasses: ''
distinctive: ''
body:
build: 标准
height: ''
posture: 自然
age_group: ''
clothing:
top: ''
inner: '【身份】女性、青年、中国、止
【面部轮廓】适中自然、适中
【五官细节】深褐、高挺、适中、自然闭合
【头发&皮肤】短发、黑色、健康小麦、自然肌理、红润健康、素颜
【体型姿态】标准、挺拔
【神态&场景】柔光箱均匀布光、中焦85mm、纯灰色专业背景'
bottom: '【身份】女性、青年、中国、止
【面部轮廓】适中自然、适中
【五官细节】深褐、高挺、适中、自然闭合
【头发&皮肤】短发、黑色、健康小麦、自然肌理、红润健康、素颜
【体型姿态】标准、挺拔
【神态&场景】柔光箱均匀布光、中焦85mm、纯灰色专业背景'
shoes: ''
style: ''
t:
name: t
description: 测试
base: 测试
personality: 默认
voice_style: 默认
art_style: 写实摄影风格
face:
shape: ''
eyes: ''
nose: ''
lips: ''
hair: ''
skin: ''
facial_hair: ''
glasses: ''
distinctive: ''
body:
build: ''
height: ''
posture: ''
age_group: ''
clothing:
top: ''
inner: ''
bottom: ''
shoes: ''
style: ''
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,136 @@
"""
composer/align.py — 画面跟随音频对齐("剪画面"策略)
用户洞察(2026-10-03):
音画不同步时,**不该动声音**(变速会变调、裁剪会丢字),
而应该**动画面** —— 画面变速几乎没有质量损失(atempo 保音高、setpts 改时长)。
适用范围(严格遵守):
· 有对口型的镜头 **不动**(口型已与音频绑定,变速会破坏)
· 无音频的镜头不动
· 变速比超出安全范围(默认 0.67~1.5 倍)→ 不动并告警(避免明显快进/慢放感)
用法:
from composer.align import align_clips_to_audio
new_clips = align_clips_to_audio(work_dir, sb, clips, lipsync_indexes={2,3})
"""
from pathlib import Path
from lib.logger import get_logger
log = get_logger("composer.align")
def _dur(p: str) -> float:
try:
from lib.ffmpeg import probe_duration
return float(probe_duration(str(p)) or 0)
except Exception:
return 0.0
def _has_audio(p: str) -> bool:
try:
from lib.ffmpeg import has_audio_stream
return bool(has_audio_stream(str(p)))
except Exception:
return False
def align_clips_to_audio(work_dir, sb, clips: list,
lipsync_indexes: set | None = None,
max_speed: float = 1.5,
min_delta: float = 0.15,
progress_cb=None) -> list:
"""把「有台词音频、但没做对口型」的镜头**变速**到音频长度
work_dir : 工作目录(音频在 {work_dir}/pre_audio/{shot_id}.mp3)
clips : 当前镜头片段路径列表(与 sb.shots 同序)
lipsync_indexes: 已做对口型的镜头序号(1-based)——这些不动
max_speed : 允许的最大变速倍率(默认 1.5,atempo 安全区 0.5~2.0)
min_delta : 小于该时长差(秒)视为已对齐,不动
返回:新的片段路径列表(失败的镜头保持原样)
"""
import subprocess
from lib.ffmpeg import FFMPEG
work = Path(work_dir)
pre_dir = work / "pre_audio"
skip = set(lipsync_indexes or ())
out = list(clips)
done = 0
for idx, shot in enumerate(list(getattr(sb, "shots", []) or []), 1):
if idx - 1 >= len(out):
break
if idx in skip:
continue # 对口型镜头不动
sid = getattr(shot, "shot_id", f"shot_{idx:02d}")
audio = pre_dir / f"{sid}.mp3"
if not audio.exists():
continue # 无音频 → 不动
adur = _dur(str(audio))
clip = Path(str(out[idx - 1]))
if not clip.exists() or adur <= 0:
continue
vdur = _dur(str(clip))
if vdur <= 0:
continue
delta = vdur - adur
if abs(delta) < min_delta:
continue # 已对齐
ratio = vdur / adur # >1 = 画面偏长,需加速
# 超出安全区**不再跳过**(跳过 = 音画依然不同步)——
# 改为:变速到安全极限 + 裁剪剩余(画面比音频长时裁掉尾部,内容损失最小)
if ratio > max_speed:
applied = max_speed
need_trim = True
elif ratio < 1.0 / max_speed:
applied = 1.0 / max_speed
need_trim = False # 画面太短:变速到极限即可(不要补黑)
else:
applied = ratio
need_trim = abs(delta) > min_delta
dst = work / f"aligned_{idx:02d}.mp4"
if progress_cb:
progress_cb("align", sid, "running", f"画面变速 {applied:.2f}x")
# 视频变速用 setpts;音频(若存在)用 atempo 同步变速
vf = f"[0:v]setpts=PTS/{applied:.4f}[v]"
if _has_audio(str(clip)):
ap = min(max(applied, 0.5), 2.0) # atempo 安全区 0.5~2.0
fc = f"{vf};[0:a]atempo={ap:.4f}[a]"
maps = ["-map", "[v]", "-map", "[a]", "-c:a", "aac", "-b:a", "192k"]
else:
fc = vf
maps = ["-map", "[v]"]
cmd = [FFMPEG, "-y", "-i", str(clip),
"-filter_complex", fc, *maps,
"-c:v", "libx264", "-preset", "fast", "-crf", "20",
"-pix_fmt", "yuv420p"]
if need_trim and applied > 1.0:
# 变速后仍偏长 → 裁到音频长度(保留开头,丢尾部多余部分)
cmd += ["-t", f"{adur:.3f}"]
cmd.append(str(dst))
r = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
if r.returncode != 0 or not dst.exists() or dst.stat().st_size < 1_000:
log.warning(" ⚠️ %s 变速失败,保留原片段: %s", sid, (r.stderr or "")[-160:])
continue
new_dur = _dur(str(dst))
out[idx - 1] = str(dst)
done += 1
_note = f"{applied:.2f}x" + ("+裁尾" if (need_trim and applied > 1.0) else "")
log.info(" ✂️ %s 画面对齐:%.2fs → %.2fs(%s,音频 %.2fs)",
sid, vdur, new_dur, _note, adur)
if progress_cb:
progress_cb("align", sid, "done", f"{vdur:.1f}s → {new_dur:.1f}s({_note})")
if done:
log.info(" ✂️ 画面跟随音频对齐完成:%d 个镜头", done)
return out
@@ -0,0 +1,678 @@
"""
AVE 06_composer/beat_sync — BGM 节拍驱动的卡点视频合成
流程:
1. BGM 节拍检测 (librosa beat_track)
2. 按 N拍 分组 (默认 4拍/组)
3. 每组分配素材 (Pexels搜索 或 用户提供)
4. 精确裁剪到组时长
5. xfade 过渡拼接 (≤8组避免 FFmpeg 崩溃)
6. 文字/字幕叠加
用法:
from composer.beat_sync import compose_beat_sync
compose_beat_sync(bgm_path="bgm.wav", output="beat_sync.mp4")
"""
import sys, os; sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import json
import os
import shutil
import subprocess
import tempfile
from pathlib import Path
from typing import Optional
from lib.logger import get_logger
from lib.ffmpeg import FFMPEG # noqa: E402 (FFmpeg 绝对路径)
logger = get_logger("beat_sync")
CACHE_DIR = Path(os.environ.get("AVE_CACHE_DIR",
str(Path.home() / "workbuddy-agent-os/agent-local/tools/ave/cache")))
BEAT_CACHE = CACHE_DIR / "beat_sync"
BEAT_CACHE.mkdir(parents=True, exist_ok=True)
def detect_beats(
audio_path: str,
group_size: int = 4,
enable_energy: bool = False,
detect_structure: bool = False,
) -> dict:
"""
检测 BGM 节拍
参数:
audio_path: BGM 音频路径
group_size: 每组节拍数 (默认4拍/组)
enable_energy: 是否提取 RMS/频谱能量特征 (用于变速卡点)
detect_structure: 是否检测歌曲结构 (Intro/Verse/Chorus/Bridge/Outro)
返回:
{
"bpm": 120.0,
"beat_times": [0.0, 0.5, 1.0, ...],
"segments": [{"start": 0.0, "end": 2.0, "beats": 4, "energy": 0.8, ...}, ...],
"structure": [{"label": "Intro", "start": 0.0, "end": 8.0, ...}],
"total_duration": 30.0,
}
"""
import librosa
import numpy as np
y, sr = librosa.load(audio_path, sr=22050, mono=True)
duration = librosa.get_duration(y=y, sr=sr)
# 节拍检测
tempo_arr, beats = librosa.beat.beat_track(y=y, sr=sr)
tempo_val = float(tempo_arr.item() if hasattr(tempo_arr, 'item') else tempo_arr)
beat_times = librosa.frames_to_time(beats, sr=sr).tolist()
# 如果没检测到拍点, 按 BPM 估算
if len(beat_times) < 2:
bpm = tempo_val if tempo_val > 0 else 120.0
interval = 60.0 / bpm
beat_times = [i * interval for i in range(int(duration / interval))]
logger.info(f" 无拍点, 按 BPM={bpm:.0f} 估算 {len(beat_times)} 拍")
else:
logger.info(f" 检测到 BPM={tempo_val:.1f}, {len(beat_times)} 拍")
# 提取 RMS 和频谱能量
rms_energy = None
spectral_features = None
if enable_energy:
# 用短时傅立叶提取帧级能量
hop_length = 512
frame_dur = hop_length / sr
rms = librosa.feature.rms(y=y, hop_length=hop_length)[0]
rms_energy = rms.tolist()
logger.debug(f" 能量帧: {len(rms_energy)} 帧, 帧时长={frame_dur:.3f}s")
# 频谱 flux
spec = np.abs(librosa.stft(y, hop_length=hop_length))
flux = librosa.onset.onset_strength(y=y, sr=sr, hop_length=hop_length)
spectral_features = {
"rms": rms_energy,
"flux": flux.tolist() if hasattr(flux, 'tolist') else [],
"hop_length": hop_length,
"frame_duration": frame_dur,
}
# 歌曲结构检测
song_structure = None
if detect_structure:
song_structure = _detect_song_structure(y, sr, duration)
if song_structure:
logger.info(f" 歌曲结构: {len(song_structure)} 段")
for s in song_structure:
logger.info(f" {s['label']:8s} {s['start']:.0f}s→{s['end']:.0f}s")
# 按 N拍 分组
segments = []
max_beats_per_seg = 0
for i in range(0, len(beat_times), group_size):
start = beat_times[i]
end = beat_times[min(i + group_size, len(beat_times) - 1)]
actual_beats = min(group_size, len(beat_times) - i)
if end - start > 0.3:
seg = {
"start": round(start, 2),
"end": round(end, 2),
"duration": round(end - start, 2),
"beats": actual_beats,
}
# 计算该段平均能量
if enable_energy and rms_energy is not None and spectral_features:
seg["energy"] = _segment_energy(start, end, spectral_features)
segments.append(seg)
max_beats_per_seg = max(max_beats_per_seg, actual_beats)
# 最后一段: 最后一个拍点到音频结束
if segments and segments[-1]["end"] < duration - 0.3:
seg = {
"start": segments[-1]["end"],
"end": round(duration, 2),
"duration": round(duration - segments[-1]["end"], 2),
"beats": 0,
}
if enable_energy and spectral_features:
seg["energy"] = _segment_energy(seg["start"], seg["end"], spectral_features)
segments.append(seg)
# 限制 ≤8 组 (xfade 链限制)
if len(segments) > 8:
keep = segments[:7]
remaining = segments[7:]
merged = {
"start": keep[-1]["end"],
"end": round(duration, 2),
"duration": round(duration - keep[-1]["end"], 2),
"beats": sum(s["beats"] for s in remaining),
}
if enable_energy and "energy" in remaining[0]:
merged["energy"] = sum(
s.get("energy", 0) * s["duration"] for s in remaining
) / max(merged["duration"], 0.1)
keep.append(merged)
segments = keep
logger.info(f" 合并为 {len(segments)} 组 (≤8)")
result = {
"bpm": tempo_val,
"beat_count": len(beat_times),
"beat_times": [round(float(t), 3) for t in beat_times], # 文档承诺的字段(此前漏返回)
"segments": segments,
"total_duration": round(duration, 2),
}
if song_structure:
result["structure"] = song_structure
if spectral_features:
result["spectral"] = spectral_features
return result
def _segment_energy(start_sec: float, end_sec: float,
spectral: dict) -> float:
"""计算某时间段的平均归一化能量 (基于 RMS + Flux)"""
frame_dur = spectral["frame_duration"]
rms = spectral.get("rms", [])
flux = spectral.get("flux", [])
if not rms:
return 0.5
start_frame = int(start_sec / max(frame_dur, 0.001))
end_frame = int(end_sec / max(frame_dur, 0.001))
start_frame = max(0, min(start_frame, len(rms) - 1))
end_frame = max(start_frame + 1, min(end_frame, len(rms)))
seg_rms = rms[start_frame:end_frame]
if not seg_rms:
return 0.5
avg_rms = sum(seg_rms) / len(seg_rms) if seg_rms else 0
avg_flux = sum(flux[start_frame:end_frame]) / len(seg_rms) if flux and len(seg_rms) > 0 else 0
# 归一化: 用全局均值和 std
rms_mean = sum(rms) / len(rms) if rms else 0.1
rms_std = (sum((v - rms_mean)**2 for v in rms) / len(rms)) ** 0.5 if rms and len(rms) > 1 else rms_mean
rms_std = max(rms_std, rms_mean * 0.01)
energy = (avg_rms - rms_mean) / rms_std * 0.3 + 0.5
return max(0.0, min(1.0, energy))
def _detect_song_structure(y, sr, duration) -> list[dict]:
"""
检测歌曲结构段落
基于 chroma 特征 + 自相似矩阵 + 时序聚类,
输出: Intro / Verse / Chorus / Bridge / Outro
"""
import numpy as np
import librosa
try:
# 提取 chroma 特征
hop_length = 2048
chroma = librosa.feature.chroma_cqt(y=y, sr=sr, hop_length=hop_length)
frame_dur = hop_length / sr
# 自相似矩阵 + 递归分割
ssm = librosa.segment.recurrence_matrix(chroma, axis=0)
bounds = librosa.segment.agglomerative(ssm, k=min(8, ssm.shape[0]))
bounds_frames = librosa.segment.subsegment(ssm, bounds, n_segments=2)
# 转换为时间
boundaries = [0.0]
for bf in bounds_frames:
t = float(bf) * frame_dur
if t < duration - 1:
boundaries.append(round(t, 1))
boundaries.append(round(duration, 1))
# 按时间比例分配标签
labels = _label_structure(boundaries, duration)
result = []
for i in range(len(boundaries) - 1):
result.append({
"label": labels[i] if i < len(labels) else "Section",
"start": boundaries[i],
"end": boundaries[i + 1],
"duration": round(boundaries[i + 1] - boundaries[i], 1),
})
return result
except Exception as e:
logger.warning(f" 歌曲结构检测失败: {e}")
return []
def _label_structure(boundaries: list[float], total_duration: float) -> list[str]:
"""根据段落在歌曲中的位置分配典型结构标签"""
n = len(boundaries) - 1
labels = []
# 按位置比例分配
positions = []
for i in range(n):
mid = (boundaries[i] + boundaries[i + 1]) / 2 / max(total_duration, 1)
positions.append(mid)
for i, pos in enumerate(positions):
if i == 0 and pos < 0.05:
labels.append("Intro")
elif i >= n - 2 and pos > 0.85:
labels.append("Outro")
elif 0.15 <= pos <= 0.65:
# 很可能在 Chorus 区
labels.append("Chorus" if i % 2 == 1 else "Verse")
elif pos < 0.15:
labels.append("Verse")
elif pos < 0.4:
labels.append("Pre-Chorus" if i % 2 == 0 else "Chorus")
else:
labels.append("Bridge" if pos < 0.75 else "Outro")
return labels
def compose_beat_sync(
bgm_path: str,
output_path: str = "beat_sync.mp4",
material_clips: list[str] | None = None,
group_size: int = 4,
resolution: str = "1080x1920",
texts: list[str] | None = None,
pexels_api_key: str = "",
pexels_search: str = "",
enable_speed_ramp: bool = False,
frame_locked: bool = True,
base_speed: float = 1.0,
high_speed: float = 1.5,
low_speed: float = 0.7,
target_bpm: float = 0.0,
) -> str:
"""
BGM 节拍驱动的卡点视频合成 (V2: 能量感知 + 帧锁定)
参数:
bgm_path: BGM 音频路径
output_path: 输出视频路径
material_clips: 素材视频/图片列表 (可选, 不提供则 Pexels 搜索)
group_size: 每组节拍数
resolution: 输出分辨率
texts: 每段叠加的文字 (可选)
pexels_api_key: Pexels API Key
pexels_search: Pexels 搜索关键词
enable_speed_ramp: 是否启用能量驱动变速 (高能加速, 低能减速)
frame_locked: 是否启用帧锁定时间线 (消除 xfade 漂移)
base_speed: 基础速度 (默认 1.0)
high_speed: 高能段速度 (默认 1.5)
low_speed: 低能段速度 (默认 0.7)
target_bpm: 目标 BPM (>0 时用 AudioLine StemAligner 预处理 BGM)
返回: output_path
"""
if not os.path.exists(bgm_path):
raise FileNotFoundError(f"BGM 文件不存在: {bgm_path}")
# 0. BPM 标准化预处理 (使用 AudioLine StemAligner)
if target_bpm > 0:
try:
from audio_line.layer4_alignment.stem_aligner import StemAligner
import librosa
y_detect, sr_detect = librosa.load(bgm_path, sr=None, mono=True)
tempo_arr, _ = librosa.beat.beat_track(y=y_detect, sr=sr_detect)
source_bpm = float(tempo_arr.item() if hasattr(tempo_arr, 'item') else tempo_arr)
logger.info(f" BPM 标准化: {source_bpm:.0f} → {target_bpm:.0f}")
if source_bpm > 0 and abs(source_bpm - target_bpm) > 1.0:
aligner = StemAligner(method="phase_vocoder")
result = aligner.align_to_bpm(
stems={"bgm": bgm_path},
source_bpm=source_bpm,
target_bpm=target_bpm,
output_dir=str(BEAT_CACHE / "aligned"),
)
if result.aligned_paths:
bgm_path = result.aligned_paths["bgm"]
logger.info(f" ✅ 标准化完成: {bgm_path}")
else:
logger.info(f" ⏭️ 无需标准化 (相近 BPM)")
except ImportError:
logger.info(f" ⏭️ AudioLine 不可用, 跳过 BPM 标准化")
except Exception as e:
logger.warning(f" ⚠ BPM 标准化失败: {e} (继续使用原 BGM)")
# 1. 节拍检测 (启用能量)
logger.info("=== Beat-Sync 卡点模式 ===")
logger.info(f"BGM: {bgm_path}")
beat_info = detect_beats(bgm_path, group_size, enable_energy=enable_speed_ramp)
segments = beat_info["segments"]
total_dur = beat_info["total_duration"]
logger.info(f"BPM: {beat_info['bpm']}, {beat_info['beat_count']}拍, {len(segments)}组")
if enable_speed_ramp and "energy" in segments[0]:
energies = [s.get("energy", 0.5) for s in segments]
avg_e = sum(energies) / len(energies) if energies else 0.5
logger.info(f" 能量感知变速: ON (均值 {avg_e:.2f}, 范围 {min(energies):.2f}~{max(energies):.2f})")
# 2. 准备素材
clips = material_clips or []
if len(clips) < len(segments) and pexels_api_key:
from material_producer.pexels.search import search_videos
needed = len(segments) - len(clips)
logger.info(f"素材不足, 从 Pexels 搜索 {needed} 个...")
kw = pexels_search or "background video"
found = search_videos(kw, count=needed, api_key=pexels_api_key, orientation="portrait")
for f in found:
clips.append(f["path"])
logger.info(f" 共 {len(clips)} 个素材")
if not clips:
raise ValueError("无可用素材")
# 3. 为每组分配素材 (循环使用)
seg_materials = [] # [(clip_path, adjusted_start, adjusted_end, adjusted_duration, speed), ...]
for i, seg in enumerate(segments):
clip = clips[i % len(clips)]
# 计算本段速度 (能量驱动变速)
speed = base_speed
if enable_speed_ramp:
energy = seg.get("energy", 0.5)
if energy > 0.6:
speed = base_speed + (high_speed - base_speed) * min((energy - 0.6) / 0.4, 1.0)
elif energy < 0.3:
speed = low_speed + (base_speed - low_speed) * (energy / 0.3)
speed = max(0.5, min(2.0, speed))
# 调整时长 (变速后实际画面时长)
orig_dur = seg["duration"]
if frame_locked:
# 帧锁定: 以 BGM 节拍为准, 用 fps 对齐到整帧
fps = 30
orig_frames = round(orig_dur * fps)
adjusted_dur = orig_frames / fps # 帧对齐后的时长
else:
adjusted_dur = orig_dur
# 变速后的素材播放时长 = adjusted_dur / speed
# 即: 如果加速 (speed>1), 需要更短的素材
material_time_needed = adjusted_dur / speed if speed > 0 else adjusted_dur
seg_materials.append((clip, seg["start"], seg["end"],
adjusted_dur, speed, material_time_needed))
logger.info(f"分配素材: {len(seg_materials)} 段, {len(clips)} 素材循环"
+ (f", 变速范围 {low_speed:.1f}~{high_speed:.1f}x" if enable_speed_ramp else ""))
# 4. 帧锁定: 预计算精确的 xfade 偏移
if frame_locked:
fps = 30
# 精确计算每段的累计偏移
cumulative_frames = 0
for idx, (clip, start, end, dur, speed, mt) in enumerate(seg_materials):
seg_frames = round(dur * fps)
seg_materials[idx] = (clip, start, dur, speed, mt, seg_frames)
logger.info(f" 帧锁定: ON (30fps)")
# 5. 渲染每组为独立视频片段 (带变速)
temp_dir = tempfile.mkdtemp(prefix="ave_beat_")
seg_files = []
for idx in range(len(seg_materials)):
clip, start_orig, dur, speed, mt, *rest = seg_materials[idx]
if frame_locked:
seg_frames = rest[0] if rest else round(dur * 30)
# 帧对齐时长
dur_aligned = seg_frames / 30.0
else:
dur_aligned = dur
seg_frames = 0
seg_file = os.path.join(temp_dir, f"seg_{idx:03d}.mp4")
# 从素材中取一段 (考虑变速需要)
clip_dur = _get_duration(clip)
offset = (start_orig * 17) % max(clip_dur - mt, 0.1) if clip_dur > mt else 0
# 先截取素材, 再变速
raw_seg = os.path.join(temp_dir, f"seg_{idx:03d}_raw.mp4")
subprocess.run([
FFMPEG, "-y",
"-ss", str(offset),
"-i", clip,
"-t", str(mt),
"-vf", f"scale={resolution.replace('x',':')},setsar=1,fps=30",
"-c:v", "libx264", "-preset", "fast", "-crf", "23",
"-an", raw_seg,
], capture_output=True, timeout=120, check=True)
if speed != 1.0 and speed >= 0.5 and speed <= 2.0:
# 变速处理
subprocess.run([
FFMPEG, "-y", "-i", raw_seg,
"-filter_complex",
f"setpts={1/speed}*PTS[v]",
"-map", "[v]",
"-c:v", "libx264", "-preset", "fast", "-crf", "23",
"-pix_fmt", "yuv420p",
seg_file,
], capture_output=True, timeout=120, check=True)
else:
os.replace(raw_seg, seg_file)
seg_files.append(seg_file)
# 6. 使用帧锁定时间线拼接
if len(seg_files) == 1:
subprocess.run([FFMPEG, "-y", "-i", seg_files[0], "-i", bgm_path,
"-shortest", "-c:v", "copy", "-c:a", "aac", output_path],
capture_output=True, check=True, timeout=60)
return _finalize(output_path, texts, seg_materials, resolution, temp_dir)
if frame_locked:
return _concat_frame_locked(seg_files, bgm_path, seg_materials,
output_path, texts, resolution, temp_dir)
else:
return _concat_xfade(seg_files, bgm_path, seg_materials,
output_path, texts, resolution, temp_dir)
def _concat_frame_locked(
seg_files: list[str],
bgm_path: str,
seg_materials: list,
output_path: str,
texts: list[str] | None,
resolution: str,
temp_dir: str,
) -> str:
"""帧锁定拼接: 用 concat demuxer 精确拼接, 无 xfade 漂移"""
if len(seg_files) == 1:
# 只有一段, 直接合并 BGM
cmd = [FFMPEG, "-y", "-i", seg_files[0], "-i", bgm_path,
"-shortest", "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", output_path]
subprocess.run(cmd, capture_output=True, timeout=120, check=True)
return _finalize(output_path, texts, seg_materials, resolution, temp_dir)
# 用 concat demuxer 拼接 (帧精确, 无过渡)
concat_file = tempfile.NamedTemporaryFile(suffix=".txt", delete=False, mode="w").name
with open(concat_file, "w") as f:
for sf in seg_files:
f.write(f"file '{os.path.abspath(sf)}'\n")
# 先拼视频
video_only = output_path.replace(".mp4", "_novideo.mp4")
cmd = [FFMPEG, "-y", "-f", "concat", "-safe", "0", "-i", concat_file,
"-c:v", "libx264", "-preset", "fast", "-crf", "23", "-an", video_only]
subprocess.run(cmd, capture_output=True, timeout=300, check=True)
# 叠加 BGM
cmd2 = [FFMPEG, "-y", "-i", video_only, "-i", bgm_path,
"-shortest", "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", output_path]
subprocess.run(cmd2, capture_output=True, timeout=120, check=True)
os.unlink(concat_file)
os.unlink(video_only)
logger.info(f" ✅ 帧锁定拼接完成: {len(seg_files)} 段")
return _finalize(output_path, texts, seg_materials, resolution, temp_dir)
def _concat_xfade(
seg_files: list[str],
bgm_path: str,
seg_materials: list,
output_path: str,
texts: list[str] | None,
resolution: str,
temp_dir: str,
) -> str:
"""xfade 过渡拼接: ≤8 段, 逐段交叉淡入淡出"""
if len(seg_files) == 1:
cmd = [FFMPEG, "-y", "-i", seg_files[0], "-i", bgm_path,
"-shortest", "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", output_path]
subprocess.run(cmd, capture_output=True, timeout=120, check=True)
return _finalize(output_path, texts, seg_materials, resolution, temp_dir)
# 构建 xfade 链
# xfade=transition=fade:duration=0.5:offset=offset1
# 每段重叠 0.5s, 累计偏移
filter_parts = []
inputs = []
offset = 0.0
prev_label = "v0"
for i in range(len(seg_files)):
inputs += ["-i", seg_files[i]]
if i == 0:
continue
# 前一段的时长
seg_dur = seg_materials[i - 1][2] if i - 1 < len(seg_materials) else 1.0
offset += float(seg_dur) - 0.5 # 减去过渡重叠
label = f"v{i}"
filter_parts.append(
f"[{prev_label}][{label}]xfade=transition=fade:duration=0.5:offset={offset:.2f}[v{i}]"
)
prev_label = f"v{i}"
# 最终输出标签是 v{len(seg_files)-1}
last_label = f"v{len(seg_files) - 1}"
full_filter = ";".join(filter_parts)
video_xfade = output_path.replace(".mp4", "_xfade.mp4")
cmd = [FFMPEG, "-y"] + inputs + [
"-filter_complex", full_filter,
"-map", f"[{last_label}]",
"-c:v", "libx264", "-preset", "fast", "-crf", "23",
"-pix_fmt", "yuv420p",
video_xfade,
]
try:
subprocess.run(cmd, capture_output=True, timeout=300, check=True)
except subprocess.CalledProcessError as e:
logger.warning(f" xfade 失败, 回落 concat: {e.stderr[:200]}")
# 回落: concat demuxer
concat_file = tempfile.NamedTemporaryFile(suffix=".txt", delete=False, mode="w").name
with open(concat_file, "w") as f:
for sf in seg_files:
f.write(f"file '{os.path.abspath(sf)}'\n")
cmd_fb = [FFMPEG, "-y", "-f", "concat", "-safe", "0", "-i", concat_file,
"-c:v", "libx264", "-preset", "fast", "-crf", "23", video_xfade]
subprocess.run(cmd_fb, capture_output=True, timeout=300, check=True)
os.unlink(concat_file)
# 叠加 BGM
cmd2 = [FFMPEG, "-y", "-i", video_xfade, "-i", bgm_path,
"-shortest", "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", output_path]
subprocess.run(cmd2, capture_output=True, timeout=120, check=True)
os.unlink(video_xfade)
logger.info(f" ✅ xfade 拼接完成: {len(seg_files)} 段")
return _finalize(output_path, texts, seg_materials, resolution, temp_dir)
def _finalize(
output_path: str,
texts: list[str] | None,
seg_materials: list,
resolution: str,
temp_dir: str,
) -> str:
"""最终处理: 叠加文字字幕 + 清理临时目录"""
import shutil
# 叠加文字
if texts and len(texts) > 0 and seg_materials:
_add_text_subtitles(output_path, texts, seg_materials, resolution)
logger.info(f" ✅ 文字叠加: {len(texts)} 段")
# 清理临时目录
if temp_dir and os.path.exists(temp_dir):
shutil.rmtree(temp_dir, ignore_errors=True)
logger.debug(f" 临时目录已清理: {temp_dir}")
return output_path
def _get_duration(path: str) -> float:
"""获取媒体文件时长(秒)
用 lib.ffmpeg.probe_duration(ffmpeg -i 解析)——
本机 ffprobe 实为 ffmpeg 的符号链接,不支持 -show_entries(会静默失败)
"""
try:
from lib.ffmpeg import probe_duration
d = probe_duration(path)
return d if d > 0 else 30.0
except Exception:
return 30.0
def _add_text_subtitles(video_path: str, texts: list[str],
seg_materials: list, resolution: str):
"""为每段叠加文字 (ASS 字幕)"""
width, height = (int(x) for x in resolution.split("x"))
font_size = max(48, int(height * 0.05))
margin_v = int(height * 0.3)
lines = [
"[Script Info]", "ScriptType: v4.00+",
f"PlayResX: {width}", f"PlayResY: {height}",
"ScaledBorderAndShadow: yes", "",
"[V4+ Styles]",
"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
f"Style: Default,Noto Sans SC,{font_size},&H00FFFFFF,&H0000FFFF,&H00000000,&H80000000,-1,0,0,0,100,100,0,0,1,3,1,2,100,100,{margin_v},1",
"", "[Events]",
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text",
]
for i, text in enumerate(texts):
if i >= len(seg_materials):
break
start = seg_materials[i][1]
end = seg_materials[i][2]
h = int(start); m = int((start - h) * 60); s = int(start - h * 3600 - m * 60)
he = int(end); me = int((end - he) * 60); se = int(end - he * 3600 - me * 60)
lines.append(
f"Dialogue: 0,{h}:{m:02d}:{s:02d}.00,{he}:{me:02d}:{se:02d}.00,Default,,0,0,0,,{text}"
)
ass_path = video_path.replace(".mp4", "_text.ass")
with open(ass_path, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
# 叠加字幕
tmp = video_path.replace(".mp4", "_tmp.mp4")
subprocess.run([
FFMPEG, "-y", "-i", video_path,
"-vf", f"subtitles={ass_path}",
"-c:v", "libx264", "-preset", "fast", "-crf", "23",
"-c:a", "copy", "-pix_fmt", "yuv420p", tmp,
], capture_output=True, timeout=120, check=True)
os.replace(tmp, video_path)
os.remove(ass_path)
@@ -0,0 +1,68 @@
"""
AVE character_locker — 角色描述块锁定器
职责:
在全片 Prompt 链中自动注入固定角色描述块,
确保 Kling 文生视频时角色一致性。
用法:
from composer.character_locker import inject_character_block
prompt = inject_character_block(segments, character_desc)
"""
import json
import os
from pathlib import Path
# ── 角色库路径 ──
CHARACTER_DB = Path(os.environ.get("AVE_CACHE_DIR",
str(Path.home() / "workbuddy-agent-os/agent-local/tools/ave/cache/character_sheet/characters")))
CHARACTER_DB.mkdir(parents=True, exist_ok=True)
def load_character_block(char_name: str) -> str:
"""从角色库加载描述块"""
safe = char_name.replace(" ", "_").replace("/", "_")
path = CHARACTER_DB / f"{safe}.json"
if not path.exists():
raise FileNotFoundError(f"角色 '{char_name}' 不存在于角色库: {path}")
data = json.loads(path.read_text(encoding="utf-8"))
return data["description"]
def inject_character_block(segments: list[dict], character_desc: str) -> list[dict]:
"""
在每段素材描述前注入角色描述块
参数:
segments: 导演脚本的 segments 列表
character_desc: 角色描述块(从定妆照生成)
返回:
注入后的 segments(直接修改原列表)
"""
for seg in segments:
mat = seg.get("material", {})
search = mat.get("search", "")
# 如果已包含角色描述, 跳过
if character_desc in search:
continue
# 注入: 角色描述 + 场景描述
if search:
mat["search_locked"] = search # 保留原搜索词
mat["search"] = f"{character_desc}, {search}"
else:
mat["search"] = character_desc
return segments
def lock_prompt(prompt: str, character_desc: str) -> str:
"""锁定单个 Prompt: 注入角色描述块(如果尚未包含)"""
if character_desc in prompt:
return prompt
return f"{character_desc}, {prompt}"
def batch_lock_prompts(prompts: list[str], character_desc: str) -> list[str]:
"""批量锁定多个 Prompt"""
return [lock_prompt(p, character_desc) for p in prompts]
@@ -0,0 +1,181 @@
"""
composer.de_ai — 去 AI 化处理链(DCS 第八节)
================================================
目的:消除 AI 生成视频的"塑料感/锐利边缘/过饱和",做出接近真人实拍(伪纪录片)的质感。
处理链(按 DCS 文档顺序):
① gblur 轻微模糊 —— 消除 AI 边缘过度锐利
② noise 胶片颗粒 —— 添加真实传感器噪点
③ eq saturation 降饱和 —— 消 AI 味的艳丽
④ curves S 曲线 —— 拉反差,更像实拍
⑤ colortemperature 色温 —— 偏移到 5300K(日光偏暖)
默认参数(DCS 默认,可被分镜的 post_process / color 覆盖):
模糊 sigma 0.4 | 颗粒 12 | 饱和 -0.12 | 色温 5300K
用法:
from composer.de_ai import DeAIProcessor
DeAIProcessor().process("in.mp4", "out.mp4") # 默认参数
DeAIProcessor().process("in.mp4", "out.mp4", blur_sigma=0.6, grain=20)
"""
from __future__ import annotations
import logging
import os
import shutil
import subprocess
from pathlib import Path
from typing import Optional
log = logging.getLogger("composer.de_ai")
# FFmpeg 绝对路径(本机装在 ~/.local/bin,不在默认 PATH)
_FFMPEG_CANDIDATES = [
os.path.expanduser("~/.local/bin/ffmpeg"),
"/usr/local/bin/ffmpeg",
"/opt/homebrew/bin/ffmpeg",
"ffmpeg",
]
def find_ffmpeg() -> str:
for c in _FFMPEG_CANDIDATES:
if c == "ffmpeg" or Path(c).exists():
if c == "ffmpeg" and not shutil.which("ffmpeg"):
continue
return c
raise FileNotFoundError("未找到 ffmpeg(请在 ~/.local/bin 或 PATH 中安装)")
class DeAIProcessor:
"""去 AI 化处理器"""
# DCS 默认参数
DEFAULT_BLUR_SIGMA = 0.4
DEFAULT_GRAIN = 12
DEFAULT_SATURATION = -0.12 # 偏移量(实际 saturation = 1 + offset = 0.88)
DEFAULT_TEMPERATURE = 5300 # 色温 K
DEFAULT_CRF = 23 # 画质(未显式指定时按**源码率自适应**,见 _adaptive_crf)
DEFAULT_PRESET = "medium"
def __init__(self, ffmpeg: str | None = None):
self.ffmpeg = ffmpeg or find_ffmpeg()
def _adaptive_crf(self, video_path: str) -> int:
"""按**源码率**自适应 CRF —— 避免对已压缩素材重编码后体积膨胀
实测:规范化后的素材(约 1.5 Mbps)用 CRF 23 重编码 → 体积膨胀到 2.7 倍;
改用 CRF 27 后回到合理区间(视觉几乎无差)。
"""
import re
import subprocess
kbps = 0.0
try:
# 用 `ffmpeg -i` 解析(本机 ffprobe 不支持 -select_streams / -show_entries,
# 用它取码率会静默失败 → 一律落兜底值)
r = subprocess.run([self.ffmpeg, "-i", str(video_path)],
capture_output=True, text=True, timeout=20)
info = (r.stderr or "") + (r.stdout or "")
m = re.search(r"bitrate:\s*(\d+)\s*kb/s", info)
if m:
kbps = float(m.group(1))
if not kbps:
# 兜底:用「文件大小 / 时长」估算
dm = re.search(r"Duration:\s*(\d+):(\d+):([\d.]+)", info)
if dm:
dur = int(dm.group(1)) * 3600 + int(dm.group(2)) * 60 + float(dm.group(3))
if dur > 0:
kbps = Path(video_path).stat().st_size * 8 / dur / 1000
except Exception:
kbps = 0.0
if 0 < kbps < 3000:
return 27 # 已压缩素材(含规范化后的)→ 不再加码率
if kbps and kbps < 6000:
return 25
return self.DEFAULT_CRF
def build_filter(self, blur_sigma: float = DEFAULT_BLUR_SIGMA,
grain: int = DEFAULT_GRAIN,
saturation_offset: float = DEFAULT_SATURATION,
temperature: int = DEFAULT_TEMPERATURE,
enable_curves: bool = True) -> str:
"""构造 FFmpeg 滤镜链(可单独测试)"""
parts = []
if blur_sigma and blur_sigma > 0:
parts.append(f"gblur=sigma={blur_sigma:.2f}")
if grain and grain > 0:
parts.append(f"noise=alls={int(grain)}:allf=t+u")
sat = max(0.0, 1.0 + float(saturation_offset))
if abs(saturation_offset) > 1e-6:
parts.append(f"eq=saturation={sat:.3f}")
if enable_curves:
parts.append("curves=all='0/0 0.5/0.55 1/1'")
if temperature and int(temperature) != 6500:
parts.append(f"colortemperature=temperature={int(temperature)}")
return ",".join(parts)
def process(self, input_path: str, output_path: str,
blur_sigma: float | None = None,
grain: int | None = None,
saturation_offset: float | None = None,
temperature: int | None = None,
crf: int | None = None,
preset: str | None = None,
keep_audio: bool = True) -> str:
"""执行去 AI 化处理 → 返回输出路径(失败抛异常)"""
inp = Path(input_path)
if not inp.exists():
raise FileNotFoundError(f"输入视频不存在: {input_path}")
out = Path(output_path)
out.parent.mkdir(parents=True, exist_ok=True)
vf = self.build_filter(
blur_sigma=self.DEFAULT_BLUR_SIGMA if blur_sigma is None else blur_sigma,
grain=self.DEFAULT_GRAIN if grain is None else grain,
saturation_offset=self.DEFAULT_SATURATION if saturation_offset is None else saturation_offset,
temperature=self.DEFAULT_TEMPERATURE if temperature is None else temperature,
)
cmd = [
self.ffmpeg, "-y", "-i", str(inp),
"-vf", vf,
"-c:v", "libx264",
"-preset", preset or self.DEFAULT_PRESET,
"-crf", str(crf if crf is not None else self._adaptive_crf(input_path)),
# ⚠️ 必须显式压回 yuv420p:滤镜链中的 curves / colortemperature 在 RGB 域
# 运算后会把像素格式提升为 yuv444p,libx264 无此参数时会保持 444p 输出
# (High 4:4:4 Predictive profile)→ 成片在微信/抖音/iOS/多数安卓上无法播放。
"-pix_fmt", "yuv420p",
]
cmd += ["-c:a", "copy"] if keep_audio else ["-an"]
cmd.append(str(out))
log.info(" 🎞️ 去 AI 化: %s → %s", inp.name, out.name)
log.debug(" 滤镜: %s", vf)
r = subprocess.run(cmd, capture_output=True, text=True)
if r.returncode != 0:
raise RuntimeError(f"ffmpeg 失败: {(r.stderr or '')[-300:]}")
if not out.exists() or out.stat().st_size == 0:
raise RuntimeError("ffmpeg 输出为空")
log.info(" ✅ 去 AI 化完成: %.1f MB", out.stat().st_size / 1024 / 1024)
return str(out)
def apply_to_storyboard(self, video_path: str, output_path: str,
storyboard=None) -> str:
"""按分镜的 post_process / color 配置处理(第一期分镜的设置)"""
kw: dict = {}
if storyboard is not None and getattr(storyboard, "shots", None):
pp = storyboard.shots[0].post_process
cc = storyboard.shots[0].color
kw = {
"blur_sigma": pp.blur_sigma,
"grain": pp.grain_intensity,
"saturation_offset": cc.saturation_offset,
"temperature": 6500 + int(cc.temperature_shift * 1000), # 偏移 → 色温
}
return self.process(video_path, output_path, **kw)
def deai_filter_preview() -> str:
"""给前端展示用的默认滤镜链"""
return DeAIProcessor().build_filter()
@@ -0,0 +1,233 @@
"""
composer/director_styles.py — 导演风格库(20 位)
来源:开源项目 DirectorSKILL(wuwangzhang1216/DirectorSKILL)的
references/director_styles/*.md,经结构化提取(一句话镜头 / 关键词 /
叙事方法 / 镜头语言 / 灯光色彩),保留可执行要点。
用途:用户选择某位导演 → 注入导演提示词 → 分镜产出该导演的镜头语言。
⚠️ 使用原则(原仓库要求):**用于原创创作**,不复制其具体镜头/台词/角色。
"""
DIRECTOR_STYLES: dict = {
"spielberg": {
"name": "斯皮尔伯格 · 奇观与亲密",
"one_line": "先拍看见的人,再拍被看见的东西;奇观永远排在一张脸后面。",
"keywords": "情感动员、经典覆盖、奇观与亲密并置、儿童视线高度、家庭裂痕、三层纵深、先反应后揭示、代价之后的修复。",
"narrative": "外部目标必须一句话说清,并在前 90 秒说清:救人、找东西、逃出去、回家、揭真相。;- 情感缺口先于奇观:第一次奇观出现前,观众已经知道主角缺什么(一句对白 + 一件道具即可)。;- 高潮由 3–5 个小目标串成,每完成一个立刻出现新障碍;不要用一个长障碍撑满。;- 亚类型可换(战争、科幻、传记),情感核心不换:亲情、失落、恐惧、勇气。",
"camera": "主力 35–50mm 建立可信的日常;反应镜头换到 135mm,机位不必逼近人物——135mm 在 3 米处就给到约 0.8 米宽的画幅(胸口以上),景别跳一大级而人不受打扰,背景同时被压平成一片色。;- 三层纵深调度:前景道具或次要人物 / 中景主角 / 背景事件或出口。观众任何一帧都能指认危险与出口。;- 运动必须有目的:推轨速度与人物步速一致(约 1.2–1.4 m/s);升降镜头只在需要交",
"light": "基础是柔和环境底光 + 一束可见硬光(窗、门缝、车灯、探照灯、投影机),硬光必须穿过薄雾或尘才看得见。;- 温暖场景比 3:1;威胁场景拉到 8:1,做法是撤掉补光让暗面自己掉下去,不是关小主光——整体照度不变,观众始终看得清空间。饱和度是另一条轴,属于调色,不参与比值。;- 每个光源在剧作里都有名字:不是\"神秘光芒\",是\"走廊尽头那盏没关的顶灯\"。写进提示",
"scene": "强情绪牵引的商业叙事:冒险、灾难、家庭创伤、童年视角、人道主义题材。;- 需要观众在开场 30 秒内认同主角的短片、预告片情绪段、品牌与公益片。;- 需要\"大事件 + 小人物\"结构,并且观众必须始终知道危险与出口在哪的动作段落。;- 不适用:反英雄叙事、道德暧昧的结局、要求观众保",
},
"hitchcock": {
"name": "希区柯克 · 悬念与信息差",
"one_line": "把秘密先交给观众,再让角色慢慢走进去;等待本身就是段落。",
"keywords": "悬念优先于惊吓、信息不对称、窥视与共谋、日常空间变危险、关键物件、延迟、几何遮挡、优雅的反派、黑色幽默。",
"narrative": "段落开始前先写一行\"观众知道 X,角色不知道 X\"。这一行写不出来,就不要用这个透镜。;- 危险的抵达时间可视化:把 20–60 秒的等待切成 3–5 个可数节拍(脚步近了、门把动了、影子长了)。;- 普通人被卷入犯罪或误认,让安全的日常生活不再可靠;主角越想解释,处境越糟。;- 另一种变体:角色知道真相却无法证明。此时观众与角色同盟,压力来自\"没有人相信\"",
"camera": "三段式主观:客观镜头看人(50mm)→ 主观所见(100–135mm 压缩,模拟凝视只挑一样东西看)→ 反应特写(50mm,比第一镜近两级——不是比中间那镜近,中间那镜拍的根本不是他)。三镜必须共享同一条视线轴。;- 广角 28mm 只用来一次性交代\"这个房间是一张地图\":门在哪、楼梯在哪、可藏身处在哪。之后不再重复给。;- 遮挡构图是默认:前景占画面 20–35%(门框、栏杆、栏杆间隙、窗帘、肩",
"light": "主光硬、单侧、有明确来源,比 4:1;阴影是几何形状(百叶、楼梯扶手、门缝),不是氛围雾。;- 明亮空间里的恐怖尤其有效:把照度维持在日常水平,让威胁没有黑暗可以躲,观众只能靠自己找。;- 服装与道具的颜色承担身份与欲望的转变:一个人物在段落里只允许换一次色彩状态,这次换色必须与信息转折同步。;- 主色中性冷灰与米白,重点色饱和度高(正红、深绿、金);对比中",
"scene": "悬疑、惊悚、犯罪、心理恐怖;跟踪戏、误认与身份错置、密室段落。;- 需要在日常空间(楼梯、厨房、办公室、车厢、洗手间)里制造威胁的段落。;- 短片与广告里需要用 20–60 秒把观众按在座位上的单场戏。;- 不适用:需要观众与角色同步无知的鬼屋类恐怖,或需要开放式意象的作者电影(",
},
"kubrick": {
"name": "库布里克 · 几何与秩序",
"one_line": "让建筑比人更有权力:广角、单点透视、匀速推进,人被压进几何里。",
"keywords": "单点透视、匀速推轨、几何秩序、高照度恐惧、黑色讽刺、理性失控、仪式化重复、分析距离、开放冷结局。",
"narrative": "先确定场景背后的制度、机器或哲学命题,再写人物;人物是命题的样本。;- 不解释心理,用重复行为展示异化:同一动作在段落里出现 2–3 次,每次只改变一个细节。;- 结构可章节化、可循环、可对称(开场与结尾用同一构图,只换内容)。;- 反讽的机制是\"形式的庄严 + 内容的荒谬\"同时在场,两者都不能软化。",
"camera": "主力 18–25mm。透视变形由机位距离决定,不由焦段决定:18mm 退到 1.5–3 米只是把人拍小,脸仍是正常比例;要拿到脸部膨胀感必须逼到 0.6–0.8 米——广角的用处正是让你贴到这个距离还能把整条走廊装进画面。两种用法都保留,不要用 35mm 去\"修正\"。;- 构图对称到像素级:消失点落在画幅正中(水平 50%,垂直 45–55%),人物骑在中轴上或严格镜像分布。;- 运动只有两种:完",
"light": "高照度、均匀;比 2:1 甚至 1.5:1。手段是成排的硬光源对称排布:投出的短硬阴影方向互相抵消——不是没有阴影,而是没有一块可以藏东西的暗部。恐惧来自\"没有黑暗可以解释这件事\"。;- 光源可以是极端自然(只用实景顶灯、窗光、蜡烛级别的低照度)或极端人工(成排灯管、投射的色块),但两者都必须在画面里可见并对称排布。;- 色彩系统化:一个空间给一个色,冷白 ",
"scene": "心理恐怖、科幻寓言、战争讽刺、制度与权力机制、失控的理性、封闭空间。;- 需要观众保持分析距离而非情感认同的段落;需要荒诞感与庄严感同时成立的仪式场面。;- 有长走廊、大厅、舱体、机房、营房这类具备强消失点的实景或生成场景。;- 不适用:需要观众共情主角、需要手持临场感、需要柔和",
},
"kurosawa": {
"name": "黑泽明 · 群像与天气",
"one_line": "用长焦把队列压成横向的带,让风雨作用在身体上,等待比爆发更长。",
"keywords": "人道主义、群像功能分工、横向运动、长焦压缩、天气作为角色、身体姿态、道德选择、悲剧尊严、代价化胜利。",
"narrative": "每个角色先定\"社会位置 + 一个道德选择\",再定性格。群像里 5–8 人,每人一句话能说清用处。;- 结构常是:混乱 → 组织 → 训练与等待 → 决战 → 空虚。等待段是全片的心脏,不是过场。;- 人物不是纯粹英雄,他们在责任、荣誉、生存与道德之间挣扎,并且会输。;- 群像叙事必须有可数的位置关系:谁在圈内、谁在圈外、谁在高处、谁背对着谁。",
"camera": "主力 100–250mm。长焦把远近人物压到同一平面上,让\"人群\"变成一堵可读的墙。方向要分清:朝镜头跑的人在 250mm 下几乎看不出在推进(纵深位移被压掉),所以冲锋要设计成\"跑了很久还没到\";横穿画面的人反而更快,因为视野窄——想让横向运动在画面里停留久一点,只能退远机位或者加长队列,不能靠焦段。;- 运动以横向为主:横移跟拍、摇摄扫过队列。纵深突入只在一次决定性的冲锋时用一次。;- 三机位",
"light": "光的作用是让天气可见:逆光让雨丝、尘埃、呼出的白气变成亮线,比 6:1 以上。;- 黑白思维优先——先确认画面在去色后依然靠明暗分区成立,再考虑颜色。;- 彩色时使用浓重的戏剧化色块表现权力与阵营(正红、靛蓝、土黄、墨黑),色块贴在旗帜、盔甲、外衣上,而不是打在空气里。;- 环境的质感高于美感:泥的反光、湿布的深色、干尘的粉白,这些是画面的主要信息来源。",
"scene": "武士片、史诗、群像、战争与社会冲突、阶级压迫、英雄牺牲、权力崩塌。;- 需要在恶劣自然环境(雨、风、雪、尘、泥)中完成情绪的段落。;- 需要 5 人以上同框且每人有明确功能与位置的调度;需要动作方向绝对清楚的战斗。;- 不适用:室内心理剧、亲密两人对话、需要浅景深与柔光的当代都市",
},
"scorsese": {
"name": "斯科塞斯 · 都市与旁白",
"one_line": "让摄影机被欲望拖着走,用音乐把上升拍得好看,再用静默让代价落地。",
"keywords": "都市脉搏、不可靠旁白、圈层规则、流行乐蒙太奇、稳定器长镜头、急推变焦、突发暴力、罪感与自辩、上升到崩塌。",
"narrative": "结构固定为四段:进入 → 上升 → 沉迷 → 坠落。四段的音乐密度依次为高、最高、中、零。;- 用第一人称旁白搭建人物的神话,再用画面证伪:旁白说\"我们都是朋友\",画面里有人被隔在门外。;- 亚文化世界必须有可被列举的规则(谁先动手、钱怎么分、谁能坐哪张桌子)。规则通过一次违规被观众理解。;- 人物不理解自己的罪,只会不断辩护;因此他不设置转折点,转折由后果",
"camera": "主力 18–27mm 用于稳定器长镜头(跟随人物穿过空间,与背景保持接触感);40–75mm 用于人物在自辩时的中近景。;- 长镜头的功能是\"入场仪式\":从公共空间连续走到私密空间,路上经过至少 3 个需要被许可的关卡。生成端把它拆成 2–3 段,接点放在有人或有物完全挡住画面的那 4–6 帧(一个横过的背影、一扇被推开的门),观众读不出接缝;工具若提供续写/延长入口,直接用它接。;- 急推变焦(",
"light": "夜景为主:霓虹、酒吧红、后厨绿、金色包厢、街灯钠黄。实用光源直接入画并且允许过曝。;- 主光可以很硬且方向混乱(多个实用光源互相打架),比 5:1;这种\"脏\"的照明就是都市的质感。;- 上升段饱和度拉高(红金为主),坠落段把饱和度降低 30–40%、把红换成灰绿,同一空间照明不改而观感改变。;- 烟雾常驻:让每个光源都有可见的光锥,画面才有厚度。",
"scene": "犯罪片、都市人物传记、道德坠落、亚文化圈层生态、男性友谊与暴力关系、宗教罪感。;- 需要\"进入一个世界\"的段落:从任何人都能进的公共入口,连续走到只有内部人能进的那个房间。;- 需要用 60–120 秒压缩数年命运的音乐蒙太奇。;- 不适用:需要观众保持冷静分析的题材、极简室内心",
},
"fellini": {
"name": "费里尼 · 狂欢与记忆",
"one_line": "怪异全部长在真人身上:脸、身体、服装、姿态;摄影机在人群里游动,不停下。",
"keywords": "游行式调度、人脸奇观、马戏团与圆舞曲、记忆逻辑、欲望与羞耻、滑稽与悲伤并置、心理舞台、意象回旋、开放结尾。",
"narrative": "用记忆与欲望做结构,不用因果。一个段落的连接理由可以是\"同一首曲子\"或\"同一种羞耻\"。;- 主角常是迷惘的创作者、浪子或观察者;他的功能是被环绕,不是推动。;- 现实滑入幻想没有边界线:让观众在事后才意识到已经越界。;- 全片选 3 个意象反复出现,每次改变一个属性(白天变夜晚、活人变照片、乐队变一个人)。",
"camera": "主力 18–32mm。正面近景用 25mm 贴到 0.5–0.7 米拍——夸张来自这个距离而不是这支镜头(同样是 25mm,退到 2 米脸就只是变小)。鼻子略大、耳朵后缩是漫画式效果,不要用长焦修正。;- 摄影机持续游动:跟移 + 摇摄组合,速度 0.4–0.8 m/s,很少完全静止。它像一个在派对里找人的宾客。;- 群体调度像游行:人物从画面左右边缘陆续入画,前景不断有人穿过(前景遮挡占 10–",
"light": "光像舞台照明:硬边光区打出看得见的边界,雾再把边界柔化半米左右;人物走进走出光区,明暗切换本身是表演。;- 雾与逆光常驻:让人群有轮廓、有层次,也让边界变得不可靠。比 4:1,但阴影里保留细节。;- 彩色时倾向华丽、人工、肉感:粉、金、酒红、天蓝,饱和度高;服装的颜色比环境更响。;- 黑白思维时靠强反差 + 雾 + 白衣黑衣的对撞分区,不靠灰阶细腻。",
"scene": "梦境段落、自传性叙事、艺术家困境、荒诞喜剧、回忆结构、城市漫游、群像仪式(游行、派对、集会、婚礼、葬礼)。;- 需要喜剧与悲伤同时在场的段落;需要主角被一群人包围但更孤独的段落。;- 时尚片、音乐录影、品牌短片里需要\"生命剧场\"感的群像调度。;- 不适用:需要严密因果的类型片情节",
},
"bergman": {
"name": "伯格曼 · 凝视与沉默",
"one_line": "把摄影机放在视线正前方,用长焦让整张脸清晰,然后不切。",
"keywords": "正面凝视、双脸并置、审讯式侧光、存在主义对白、沉默作为台词、室内即牢笼、剥皮式揭露、不治愈的结尾。",
"narrative": "人物 2–3 个,空间 1–2 个,时间跨度不超过一夜。外部事件越简单越好。;- 真正的戏剧来自关系里的谎言、依赖、怨恨与羞耻,不来自情节推进。;- 对白像剥皮:每一轮交换都比上一轮更接近人物最不愿说的那句话。写对白时给每一轮标一个\"被剥掉的第几层\"。;- 全场设一次\"权力翻转\":谁在问、谁在答,必须在中段互换一次。",
"camera": "主力 75–150mm 拍脸。75mm 给中近景,100–150mm 给大特写;长焦不放大鼻子也不缩耳朵,脸被拍成它本来的比例,观众找不到一处变形可以拿来替他解释。;- 正面凝视是核心手段:摄影机在视线轴线上(偏差 <10 度),脸占画幅高度 60–80%。这是这个透镜最贵也最有效的一招,一场戏用 2–4 次就够。;- 双脸构图:一张脸正面在中景平面,另一张脸侧面在近景平面,两张脸的边缘接触或轻微",
"light": "单侧硬主光,比 6:1,暗面不补光但保留可辨认的轮廓。光源方向决定谁在审问谁。;- 光源常是窗、台灯、火;把光位设在演员正侧方(90 度)而不是 45 度,这样鼻影会把脸分成两半。;- 黑白思维:先确认画面在去色后靠脸的明暗雕塑成立。彩色时也保持低饱和、室内化、冷偏(灰蓝、灰绿),只留皮肤是暖的。;- 背景压到近乎全黑或全白的单色平面,让脸没有可依靠的环境信",
"scene": "室内剧、婚姻与亲密关系冲突、信仰危机、死亡焦虑、家庭创伤、忏悔与审问、心理崩溃。;- 两人对话场景;单人独白;需要在 3–5 分钟内不换景完成情绪全程的段落。;- 需要用最少资产(一个房间、两张脸、一扇窗)做出高强度戏剧的低成本项目。;- 不适用:动作、群像、需要空间奇观或类型快",
},
"tarkovsky": {
"name": "塔可夫斯基 · 雕刻时间",
"one_line": "镜头站着不动,让水、火和时间在画面里替人物完成表演。",
"keywords": "雕刻时间、长镜头、记忆、信仰、牺牲、水、火、风、泥、废墟、梦境、精神流亡、质地、垂直压力。",
"narrative": "先定一个精神问题(我能否被原谅/信仰是否值得代价),再设计事件;不要先设计反转。;- 外部旅程与内部旅程一一对应:走向一个具体地点=走向一次内在清算。;- 梦、回忆、现实、预感之间不加标记地切换,观众靠情绪而非逻辑跟随。;- 一场戏只推进一个精神刻度,允许\"什么都没发生\"的整场戏存在。",
"camera": "主力焦段 28–35mm,f/5.6–f/8 深焦:前景的水面、中景的人、远处的门要同时清晰,用景深而不是切镜建立层次。;- 深焦是有代价的:35mm f/8 的超焦距约 5 米,把焦点放在那里,清晰范围是 2.5 米到无限远。所以前景反光面要摆在 2.5 米以外;贴到 1 米内的前景在这个光圈下一定糊,那是另一种镜头(质地特写),不能和深焦分层混在同一个镜头里。;- 移动只有两种:恒速极慢横移(",
"light": "反差比 2:1–3:1,柔阴天窗光为主,几乎不出现硬边阴影。;- 唯一允许的明亮点光源是火:烛、油灯、篝火、燃烧的木头。火只做点光和运动源,永远不做主光——主光始终是柔的阴天窗光,所以即使画面里有明火,也不该出现硬边阴影。;- 主色调是泥棕、湿石灰、苔绿、褐调;高光偏乳白,重点色只有火橙和铁锈红。;- 低饱和低对比,但不是灰蒙蒙——湿表面自己反光,质地承担对",
"scene": "诗电影、精神与信仰危机、科幻寓言、记忆叙事、废墟与被自然侵入的空间、慢电影、梦与现实无缝交叠的段落。也适合极低预算但可控环境的项目:一个房间、一堵湿墙、一盆水就能成段。;不适合:需要在 30 秒内交代三条信息的商业片、动作片、任何以剪辑节奏为卖点的内容。",
},
"wong_kar_wai": {
"name": "王家卫 · 都市孤独",
"one_line": "摄影机贴得太近、挡得太多、又晚了一步,正好是这段关系的形状。",
"keywords": "错过、暧昧、时间标记、记忆重写、孤独、霓虹污染、步进印片、手持、前景遮挡、旁白、都市迷宫、怀旧流行乐。",
"narrative": "先设计\"差一点发生\":两个人共享同一个空间但错开 30 秒,或共享同一个物件但不共享时间。;- 用具体数字承载情感:日期、门牌号、班次、第几次、几分钟、还剩几个。抽象情绪一律换成可数的东西——自己发明一个可数物,不要借用别人已经用过的那一个。;- 旁白是第一人称,真诚但不可靠——它在为自己重写事实,观众要能看出画面与旁白的差额。;- 结构碎片化:同一个动作在",
"camera": "焦段两极:18–25mm 贴到 40cm 内拍狭窄空间和变形的近身;85mm 隔街压缩人群,中间焦段少用。;- 手持是默认,但幅度小:像人在犹豫时的微调,不是奔跑时的晃动。;- 步进印片感(step-print):低帧率拍摄(8–12 fps)配长快门(约 1/8 秒),再把每一帧重复打印 2–3 次填回正常时长。拖影来自那个长快门,不是来自抽帧——只抽帧不开慢门只会得到卡顿。一段只用一次。;- ",
"light": "反差比 6:1–10:1,光比全靠实用光(霓虹管、路灯、走廊灯泡、冰箱灯、电视机),不带补光。;- 分清哪些实用光是硬的:裸灯泡、射灯、窗外招牌的直射是硬源,负责硬边和高光比;霓虹管是细长的线光源,本身偏柔,它的活是染色和勾轮廓,别指望它给你硬边阴影。;- 一个空间只用两三种颜色,且互补:绿与品红、金与青、红与墨绿。颜色是情绪滤镜,不必符合物理。;- 高饱和",
"scene": "都市爱情与错过、暧昧未成形的关系、记忆叙事、夜景与狭窄室内、人物独白、怀旧年代空间、情绪向短片与音乐段落。空间越小越好:走廊、楼梯、单间、面摊、后座。;不适合:需要清楚交代地理关系的场景、群戏调度、任何观众必须看懂\"谁在哪里\"的动作段落。",
},
"nolan": {
"name": "诺兰 · 高概念谜题",
"one_line": "先把规则讲清楚,再让三条时间线同时向同一个倒计时收拢。",
"keywords": "高概念规则、倒计时、交叉剪辑、实拍质感、谜题结构、理性人物、执念、牺牲、物理重量、情感压抑。",
"narrative": "第一步:用一句话写出规则(不超过 25 字),且这句话必须在前 90 秒内以戏剧方式说出或演示出。;- 规则必须同时是情感陷阱:遵守它会让主角失去他最在乎的东西。;- 拼图式给信息:观众先困惑 20%,再补 60%,最后 20% 留到高潮才补齐;每次补齐都要伴随一次代价。;- 多线并行:2–4 条线各有独立倒计时,且倒计时长度不同(例如 A 线 60 分钟、",
"camera": "焦段偏广:14–21mm 拍尺度与空间关系,35mm 拍贴身跟随,50mm 是常规上限。更长的焦段全片只留给一种用途——把分处两地的两条线压进相似的画面形状里做匹配剪辑——用完立刻退回广角。;- 广角+近距离=物理存在感:让镜头离演员 60–90cm,用 21mm 拍,观众能感到重量和体积。;- 手持与稳定器混用,但手持是\"跟得上\"的手持,不是失控;动作段落每个镜头只表达一个机制事实。;- 大远景",
"light": "反差比 3:1–4:1,自然主义动机;奇观也要像是真实发生并被摄影机偶然拍到。;- 主色调冷:混凝土灰、钢青、夜间钠灯的暖橙作唯一暖点;饱和度中低。;- 不同时间线用可辨识但不夸张的差异区分:色温差 500–800K、颗粒差、天气差、镜头畸变差,而不是换成完全不同的滤镜。;- 室内多用可见的实用光源(工作灯、仪表、车灯);室外用真实天光和阴天散射。",
"scene": "高概念科幻惊悚、时间与记忆结构、心理谜题、身份困境、任务片、多线并行高潮、需要\"观众边看边学规则\"的段落。也适合预算有限但结构可以炫技的短片:一个规则、两个空间、三条线。;不适合:氛围向诗电影、纯人物关系剧、没有可陈述规则的情绪散文。",
},
"villeneuve": {
"name": "维伦纽瓦 · 宏大克制",
"one_line": "把人缩到画面高度的百分之五,然后给他二十秒,什么也不解释。",
"keywords": "宏大尺度、几何对称、单色相、雾与沙、低频轰鸣、寂静、缓慢丈量、纪念碑式建筑、人对系统、克制悬念、形而上恐惧。",
"narrative": "主角是专业人士(调查员、士兵、技术员、研究者),带着一套方法论进入一个方法论失效的地方。;- 不解释:把揭示延迟到观众已经形成错误假设之后,让理解本身成为情节事件。;- 事件规模远超主角的处置能力,所以主角的动作永远是\"观察、记录、承受\",而不是\"解决\"。;- 哲学问题先行(时间、记忆、宿命、暴力的代价),奇观是它的物证。",
"camera": "焦段:21mm 拍纪念碑尺度,40mm 拍程序化中景,75mm 压缩疲惫的脸;人物在广角镜头里占画面高度的 5%–15%。;- 运动只有三种:锁死、恒速横移(像在丈量墙面)、恒速推进(正对灭点)。禁止手持、禁止环绕、禁止变速。;- 强制留出\"空镜时间\":镜头在人物进入前 2 秒开始,在人物离开后 3 秒结束。;- 构图靠中心对称或单一水平线;把地平线放在画面的 1/8 或 7/8,不要放中间。",
"light": "一个段落一个主导色相,绝不混:赭橙沙漠、青雾、铜金室内、墨绿林地、灰白冰面。换色相=换地点或换时间层。;- 逆光为主,光比 5:1–8:1——这个比值指的是逆光面与正面补光之比,不是常规的主光对补光,所以人物是剪影或半剪影,正面只留到轮廓可读为止。;- 柔散射,几乎不出现硬边阴影;光源常是一整面墙、一整扇窗、一整片天。;- 低饱和但高色相纯度:颜色很淡,但整",
"scene": "科幻寓言、第一接触、缓慢恐惧、犯罪程序剧、巨型建筑与荒漠地貌、人在系统性力量前的渺小感、需要\"看不清也听不清\"作为主题的段落。低成本也可成立:一堵大墙、一台雾机、一个逆光窗。;不适合:需要密集信息交付的段落、喜剧、群戏对白、任何要求观众跟上快速因果链的内容。",
},
"fincher": {
"name": "芬奇 · 外科精确",
"one_line": "用长焦把人从背景里切下来,再从他不知道的位置上看着他。",
"keywords": "外科精确、信息密度、监视视角、青绿-琥珀、低饱和低对比、长焦样本化、实用光唯一、机位可复现、程序节奏、反高潮。",
"narrative": "结构由信息流动决定:先画出\"谁在第几分钟知道什么\"的表,再写场景。;- 节拍来自工作行为:数据、笔录、屏幕、会议、走访、归档;人物通过职业动作暴露内心。;- 观众的知情度略高于主角约一个证据单位——不是完全同步,也不是全知。;- 暴力不被浪漫化:冷静、突然、无配乐前摇,且后果延迟到下一场才显现。",
"camera": "焦段偏长:85mm 与 135mm 是主力,35mm 只用于一次性交代几何;长焦压缩把背景压成色块,人物被\"抠\"出来。;- 机位可复现:写清高度、距离、角度,同一场戏的同一机位必须能精确回到原位;这是与\"差不多的稳定\"最大的区别。;- 推进以百分比计量:一个 8 秒镜头推进画幅宽度的 3%–6%,慢到观众意识不到,但结束时构图已经变紧。;- 监视机位是签名:从玻璃后、门缝、走廊尽头、货架之间、天花",
"light": "青绿阴影与琥珀实用光的两级对位;不是整体偏色,是阴影偏青、光源偏黄。;- 光比 4:1–6:1,但对比度整体压低:黑位抬起,阴影里始终保留可读纹理,绝不压死黑。;- 实用光源常是唯一光源:台灯、显示器、街灯、走廊应急灯、车灯;写提示词时点名光源实体,不写氛围。;- 饱和度低但不去色:皮肤保留一点血色,其余颜色被抽走。",
"scene": "犯罪悬疑与连环案、调查与程序叙事、技术行业与媒体操纵、心理冷酷的家庭暴力、职业流程剧、阴冷都市夜景。特别适合\"信息不对称\"驱动的段落:谁知道什么、何时知道、怎么被揭穿。;不适合:需要观众与人物共情到失控的段落、手持纪实、温暖家庭剧、任何靠镜头晃动制造紧张的内容。",
},
"refn": {
"name": "雷弗恩 · 霓虹停滞",
"one_line": "机位锁死、人在正中、什么都不动,让颜色替所有人做完这场仪式。",
"keywords": "霓虹色块、正面对称、催眠停滞、合成器音墙、仪式动作、缓慢凝视、突发暴力、身体物化、平面化构图、无解释寓言。",
"narrative": "情节压到寓言级:欲望、复仇、嫉妒、自毁,四选一,且不加动机说明。;- 用仪式动作推进而不是因果链:穿衣、洗手、走向房间中央、跪下、把一样东西放在正中。;- 人物动机藏在沉默里,只通过行为暗示;观众允许猜错。;- 暴力是审美事件,不是问题的解决方案;它不改变人物,只完成一个形式。",
"camera": "焦段 25mm(把房间拍成舞台)/40mm(仪式中景)/75mm(锁死的脸部肖像)。25mm 只用来正面平行地拍整堵墙,且人物离镜头 3 米以上——这样广角的变形不会显形;一旦贴到 1.5 米内拍人,脸就会鼓出来,对称的仪式感立刻变成怪诞,那是另一套语言。;- 机位胸高、与墙面成 90 度、绝对居中;人物的中轴与画面中轴误差为零。;- 运动预算是零:整段不动。若必须动,只允许一次恒速正面推进,速度",
"light": "极端色块:纯品红、电光蓝、血红、月光紫;一个镜头最多两种颜色,各占约一半画面,边界硬。分界线可以偏离画幅中线 ±10%,但必须落在一条画面里本来就有的竖直边上(门框边、墙角、柱子),否则观众会读成打光失误而不是设计。;- 光比 8:1–12:1,光源像舞台灯:硬边、有明确的落点、可以直接看见颜色的分界线。;- 单一色温支配整段;换颜色=换心理状态或换叙事阶段",
"scene": "风格化犯罪、寓言式短片、时尚惊悚、缓慢恐怖、宗教或仪式感空间、沉默角色与极端美学并存的段落。这套语言对生成模型格外友好:无位移、无复杂调度、单一光源、单一平面,能出错的地方比其他风格少一个数量级——你几乎只需要守住构图不塌。;不适合:需要交代因果的叙事段落、群戏对白、任何依赖观众",
},
"bi_gan": {
"name": "毕赣 · 梦境长镜",
"one_line": "摄影机像一个没睡醒的人,沿一条能画在地图上的路,一直走进梦里。",
"keywords": "雾镇、超长长镜头、路径、穿越式转场、梦境拼接、诗歌旁白、时间循环、潮湿反光、霓虹与乡土并置、磁带与老歌、记忆寻人。",
"narrative": "故事围绕\"寻找一个人或一段记忆\",但寻找的过程就是全部叙事,找到与否不重要。;- 现实、梦境、回忆、预感被有意混淆,且不加任何标记;观众跟随情绪而非逻辑。;- 时间可循环、倒流、并置:同一空间在段落后段再次出现,但物件或人已经不同。;- 诗歌旁白承担象征任务,与当下画面错位;它谈论的对象不在画面里。",
"camera": "焦段 18–25mm 为主,f/2.8–f/4;广角让狭窄空间可通行,同时把湿反光铺满画面。贴到 1.5 米跟拍时,25mm f/3.5 的景深不到一米,背后的霓虹和灯泡自然化成光斑——这正是要的,不要收小光圈去救背景,收了整条街就变成资料片。注意这层虚化只在近端成立:退到 3 米,同样的焦段光圈景深就回到两米以上,背景重新变清楚,所以\"贴着人走\"不是风格偏好,是拿到这个背景的技术前提。;- 摄影",
"light": "主光源是潮湿表面的反射:积水、湿砖、玻璃、雨衣、瓷砖;先设计水,再设计灯。;- 混合色温是常态且不校正:卡拉 OK 招牌的霓虹、台球桌上的荧光管、廉价白炽灯泡、电视机的青光同时存在。;- 色调湿润青绿、暗紫、淡橙,低饱和但层次厚;对比低,暗部有雾散射的抬升。;- 雾天自然光形成弥散柔光,几乎无硬阴影;光比 2.5:1–3.5:1。",
"scene": "华语小镇叙事、诗电影、梦与现实交叠、寻人与寻记忆、潮湿西南地貌、低成本高氛围段落、长镜头实验、魔幻现实主义。适合有真实场地但没有预算做复杂分镜的项目:一条路线就是一场戏。;不适合:需要多机位覆盖的对白戏、剪辑驱动的类型片、任何需要观众看懂因果的段落。",
},
"zhang_yimou": {
"name": "张艺谋 · 单色仪式",
"one_line": "先决定这一段是什么颜色,再决定谁站在里面;颜色说话,人只是破格点。",
"keywords": "单色场、图案与破格、群体几何、民俗仪式、院落对称、材料色、顶俯图形、集体规训、命运的可见形状、色彩宣告。",
"narrative": "先写\"颜色分段表\":全片 2–4 个色段,每段一句话写清这个颜色代表什么;换色必须落在一次剪辑点上,且对应一次不可逆的处境变化。;- 冲突结构是\"个体 vs 规矩\":一个人想要一件很小的东西,一套集体制度不允许。制度必须有可见形式——队列、称量、报数、开门关门的时刻表。;- 仪式承担说明功能:不解释规矩,演一次规矩,观众自己看懂。一场仪式只演一次完整流程,第",
"camera": "主力 35mm 做天井与长廊的单点透视对称;85–135mm 把重复单元(列队、屋檐、灯笼、门洞、梯田)压成平面图案。24mm 以下慎用——广角会破坏单元的等距,图案立刻散掉。;- 机位只用两档高度:眼平 1.6m 锁定,用于正面对称;顶俯 90° 用于把人群变成图形。中间高度会同时失去对称和图形,尽量不用。;- 运动只有三种,每种都恒速无加减速:与一排单元平行的横移(0.3–0.5 m/s)、对",
"light": "单场单主色:一个段落只允许一个主色占据 60%–80% 画幅。换色即换情绪刻度,且换色必须发生在剪辑点上,不做渐变。;- 外景用高照度硬光,比 3:1,让材料的织纹和颗粒被看见。3:1 是靠地面和同色墙面的大面积反射把暗部托起来的,不是靠加灯;没有这层反射的空旷外景,硬太阳自然会跑到 6:1 以上,那就不是这个风格了——所以选景时先看有没有能反光的大面积同色",
"scene": "民俗与仪式题材:婚丧、节庆、宗族规矩、集体劳作、大型团体操演段落(方阵、齐舞、集体仪典)。;- 需要用视觉宣告情绪的段落:情绪不靠台词、不靠特写,靠整块画面换色完成。;- 院落、天井、长廊、梯田、旷野、城墙等有强几何秩序的空间。;- 群体资源可控的项目:只要有 30 个以上可重复",
},
"hou_hsiao_hsien": {
"name": "侯孝贤 · 固定远观",
"one_line": "摄影机提前到场,坐在门外,不走近也不离开,让一屋子人把时间过完。",
"keywords": "固定长镜、远观、门框纵深、实时、省略、环境声、日常行为、非表演、家族时间、画外事件。",
"narrative": "结构靠省略:把最有戏的事件放在两颗镜头之间。观众看见事前的一顿饭和事后的一顿饭,中间那件事永远不拍。;- 一场戏=一个连续时间块,不做时间压缩。如果吃饭要 4 分钟,就拍 4 分钟里的一段,不剪成 40 秒。;- 信息由行为携带:谁坐在哪、谁给谁盛饭、谁不说话、谁先离席。人物关系全部靠座次和动作分配交代。;- 大历史只从缝隙里进来——广播、一句转述、一个不再",
"camera": "主力 50–100mm,机位距被摄体 6–12 米。用中长焦从远处拍全景,而不是用广角贴近拍——这一条决定了整个风格:观众离得远,但看得清。;- 机位高度是坐姿眼高 1.0–1.2 米(板凳、榻榻米、饭桌的高度),不是站姿眼高。整场不变。;- 三脚架锁死。唯一允许的运动是\"补构图\":人物快要出画时,10–20 度的极慢摇,速度慢到观众意识不到,摇完就停。禁止推、拉、升降、跟。;- 门框、窗框、廊柱",
"light": "只用现场光。主光是门口或窗口的白天户外光,室内比它低 1–2 档;人脸经常处在下半档的暗部,允许看不清。;- 光比 2:1–4:1,柔,没有硬边阴影;夜戏由一盏真实的顶灯或日光灯管完成,比 4:1,灯本身可以入画。;- 不给演员补光。人走到窗边就亮,走进屋里就暗——曝光变化本身是场面调度的一部分。;- 色调来自实景材料:旧木头的褐、灰泥墙的白、门外的植物绿、",
"scene": "家庭史、时代变迁下的日常生活、代际沉默、离开与返乡、小城与街屋空间。;- 需要\"事件发生在画外\"的叙事:变故已经发生,我们只看见之后的一顿饭。;- 极低成本高完成度的项目:一个真实房间、一扇门、一个下午的自然光就能拍一场。;- 需要观众自己在长时间里发现信息的段落——他必须扫描画",
},
"park_chan_wook": {
"name": "朴赞郁 · 巴洛克反讽",
"one_line": "把画面塞满花纹,让摄影机做一件物理上不可能的事,然后礼貌地伤害一个人。",
"keywords": "巴洛克、纹样密度、反讽之美、礼貌残酷、不可能的运动、对称正面、道德账目、形式高潮、精确暴力、后果的重量。",
"narrative": "结构是账本:谁欠谁什么,写成一张可核对的表。每一次暴力都必须记账,并在后面某处被结算。;- 复仇者与被复仇者共享同一套礼节。他们互相称呼得体、请对方坐下、倒茶、道歉——恐怖来自礼节没有中断。;- 用形式而不是台词交代秘密:一次镜像、一件重复出现的织物、两个不同时间点在同一位置的相同构图。;- 允许一次结构性的反转,但反转必须让此前所有礼貌行为的含义反过来,而",
"camera": "主力 40–50mm 做正面对称;28mm 只在那一次不可能运动里用(需要把整个装饰空间收进来);85mm 给装饰性插入镜头(手、餐具、绣线、纽扣)。;- 每场戏只允许一次\"不可能运动\",且写成一条有起点和终点的路径:例如从 90 度顶俯的物件开始,下降并旋转 90 度,落在正面对称的人物中景上;或沿一面纹样墙横移,穿过墙体,继续在隔壁房间横移。多做一次,风格就变成噪音。;- 写这条路径时把后退量",
"light": "主光是暖实用光(台灯、壁灯、廊下灯笼、窗户的斜射日光),必须以掠射角打在有纹样的表面上,让图案获得浮雕感——正打光会把花纹拍平,等于白做。;- 常规光比 3:1;暴力发生的那一颗拉到 8:1,方法是关掉补光而不是加硬光,让阴影自己吃掉一半的花纹。;- 冷色只从窗外进来,作为暖室内的对位,占画幅不超过 20%。;- 主色:酒红、橄榄绿、芥末金、胡桃木褐;重点色",
"scene": "复仇、家族秘密、阶级伪装、囚禁与交换、道德账目型犯罪叙事。;- 需要\"美\"与\"残酷\"同时在场的段落:一次伤害发生在一个非常好看的房间里。;- 有美术预算或有真实纹样环境的项目:壁纸、地砖、刺绣、木雕、庭院植栽、旧式旅馆。;- 需要一次让观众\"看见摄影机\"的形式高潮:一个镜头做完一",
},
"malick": {
"name": "马力克 · 自然光诗意",
"one_line": "镜头贴着一只手,太阳在她背后压着地平线,摄影机以步速漂移,不停。",
"keywords": "自然光、魔术时刻、广角贴近、步速漂移、手势、自然插入、耳语旁白、联想蒙太奇、无情节、风景中的身体。",
"narrative": "不写情节点,写\"记忆序列\":一串可以任意排序的片段,每段一句话(谁、在哪、做一个什么手势)。;- 时间是主观的:现在、回忆、想象之间不加标记地切换,观众靠旁白的人称和光的方向分辨。;- 旁白是内心的第二人称或对缺席者的呼告,8–15 个字一句,不描述画面,也不解释情节;它提问,画面不回答。;- 人物之间的关系由身体距离与朝向交代:谁走在谁前面、谁被落下、谁转",
"camera": "主力 14–28mm,且必须贴近:拍手时镜头离手 30–50 厘米,拍脸时 60–90 厘米。广角加贴近=前景巨大、地平线在后面,这是这个风格的全部秘密。35mm 只留给贴身跟走的中近景,再长就跟不上人也压不出前景。;- 摄影机永不锁定。手持或轻稳定器,以人的步速漂移(0.8–1.2 m/s),低频晃动,没有一次急停急起。禁止三脚架,禁止对称正面。;- 机位高度持续变化,0.3–1.8 米之间来回",
"light": "只用现有光。允许的器材是一块白卡或一面反光板,不允许打灯。做不到就不拍这场。;- 拍摄窗口:日出后 20–35 分钟或日落前 20–35 分钟,太阳高度 2–10 度。太阳每小时最多爬升约 15 度(纬度越高越慢),所以这个高度带只能对应半小时上下的窗口,别按\"一整个金色小时\"排通告。阴天全天可用,晴天正午不可用。;- 太阳放在人物后方 150–210 度范",
"scene": "回忆、成长、失去、信仰、家庭裂痕这类没有外部目标的抒情叙事。;- 品牌与公益片中需要\"感受先于信息\"的段落;婚礼、纪念、季节性影像。;- 有真实外景与可等待的日程的项目:能守到日出后 30 分钟或日落前 30 分钟。;- 素材式拍法:先拍大量碎片,在剪辑台上找出顺序——适合没有完",
},
"michael_mann": {
"name": "迈克尔·曼 · 夜色与专业",
"one_line": "夜里的城市是一块发着冷光的平原,一个懂行的人站在它前面,谁也不认识他。",
"keywords": "都市夜、市政光源、混色温、地平线、专业能力、程序精确、高感噪点、玻璃反射、职业孤独、城市噪声即配乐。",
"narrative": "人物由能力定义:出场前 60 秒内必须让观众看见他把一件专业的事做对,不靠台词说明身份。;- 双主角结构:对立的两方共享同一套职业伦理和作息,用平行剪辑并置两条日常线(同样的准备、同样的等待、同样的收尾),让观众先认同能力,再被迫选立场。;- 私人生活以碎片方式插入,永远被工作打断,而且从不解决;感情戏的功能是标出这个人不能拥有什么。;- 流程即情节:把一次",
"camera": "主力 18–25mm 做\"室内也有地平线\":机位退到房间深处,让整面窗和天际线成为人物背后的背景;35mm 做双人;50mm 只给流程插入(手、工具、接口、时间显示)。;- 机位眼平 1.6m,光轴保持水平。仰俯角一律不用,除非从车里拍或从高处看城市。人被压小是靠距离和构图,不靠角度。;- 人物在画幅中的高度目标是 8%–15%——按高度算,不要按宽度:25mm 镜头下 22 米外的人只占画幅宽度",
"light": "不打电影灯。每一个光源都要说得出它在城里的名字:钠灯路灯、汞灯停车场、便利店的荧光管、LED 广告、车头灯、写字楼层的顶灯、施工照明。;- 同一帧里必须有至少两种不同色温对抗(例如左侧钠灯橙、右侧汞灯青绿),不要统一白平衡把它们调成一个颜色——那正是最常见的失败。;- 曝光按实用光源给,不按脸给:脸可以低于主光 1.5–2 档,允许看不清,允许出现可见的高感",
"scene": "犯罪、盗窃、监视、执法、走私、网络安全这类\"专业者对专业者\"的题材。;- 需要把流程拍成戏的段落:装配、勘察、等待、交接、撤离——每一步都有自己的镜头。;- 都市夜外景:停车楼、高架下、机场外围、码头、深夜写字楼、住宅塔楼的外挂走廊。;- 需要\"孤独=距离\"的情绪:人物之间永远隔",
},
"coen_brothers": {
"name": "科恩兄弟 · 冷峻荒诞",
"one_line": "把画面摆得一丝不苟,让演员绝对认真地演,然后比该切的时候多留一秒。",
"keywords": "冷面、几何、地域具体性、广角低机位、命运是个平板笑话、能力反噬、荒诞记账、多留一秒、正面死中、难看的顶光。",
"narrative": "先写\"计划书\":主角的方案分 5–8 步,每步都合理。然后找出其中恰恰因为执行正确才引发灾难的那一步——那是全片的引擎。;- 引入一个不理解这套计划的外部力量(天气、一个较真的职员、一条法规、一只动物),它不邪恶,它只是不参与。;- 暴力必须记账:每一次伤害之后立刻拍善后——擦、搬、填表、解释、赔钱。观众的不适来自后勤,不来自血。;- 至少一个角色是道德常识",
"camera": "主力 14–21mm 做低机位运动与怪诞近景;27mm 做冷面正面对称;40mm 只留给全片唯一一次不开玩笑的单人镜头。;- 机位是二元的:要么完全锁定正面死中(眼平 1.6m),要么一次硬性机械运动——0.4–0.8 米高的低机位推轨跑在行走者前方或后方,恒速直线。没有\"飘\",没有微调构图。;- 广角低机位跑动镜头的规格:镜头高度 0.4–0.6 米,上仰 10–15 度,速度与被摄体一致;14",
"light": "光要难看得有道理:顶部荧光灯管、一只裸灯泡、雪地反光、汽车旅馆的床头灯。顶光在每个人眼窝里留下阴影,让称职的人看起来像傻子——这是这个风格的核心照明策略。;- 室内比 3:1,夜外景 8:1;不用柔光箱美化人脸,不用发丝逆光。;- 每个空间有一个统一的、廉价的色偏:办公室是荧光管的绿白,汽车旅馆是钨丝的橙棕,雪地是无色的高亮,警局是日光灯的青白。;- 主色:",
"scene": "黑色喜剧、犯罪失控、绑架与勒索、小人物执行大计划、官僚与流程荒诞。;- 强地域性题材:某个具体的县、某种口音、某种天气、某种连锁店——具体到能说出品类名称。;- 需要观众\"笑得不确定\"的段落:同一颗镜头里既有滑稽也有真实的伤害。;- 低成本可行:一条走廊、一间办公室、一片空地加一",
},
}
def get_director_style(key: str) -> dict:
"""按 key 取导演风格(未知 key 返回空 dict)"""
return DIRECTOR_STYLES.get((key or "").strip()) or {}
def director_style_block(key: str) -> str:
"""导演风格 → 提示词块(供导演系统注入)
只注入**可执行要点**(约 150-250 字),保持提示词简洁:
核心视角一句 + 关键词 + 镜头语言前两条 + 灯光首要点
"""
st = get_director_style(key)
if not st:
return ""
def _first(s: str, n: int = 2) -> str:
parts = [x.strip(" -") for x in str(s or "").split(";") if x.strip(" -")]
return ";".join(parts[:n])[:220]
out = [f"【导演风格:{st['name']}】"]
if st.get("one_line"):
out.append(f"· 核心视角:{st['one_line'][:90]}")
if st.get("keywords"):
out.append(f"· 风格关键词:{st['keywords'][:80]}")
cam = _first(st.get("camera"), 2)
if cam:
out.append(f"· 镜头语言:{cam}")
lit = _first(st.get("light"), 1)
if lit:
out.append(f"· 灯光:{lit}")
out.append("→ 用以上镜头语言与光影逻辑设计分镜(原创内容,不照搬具体镜头/台词)。")
return "\n".join(out)
def director_style_options() -> dict:
"""供前端下拉:{key: 中文名}"""
return {k: v.get("name", k) for k, v in DIRECTOR_STYLES.items()}
@@ -0,0 +1,607 @@
"""
composer/dubbing.py — 配音合成(TTS 人声 + 环境音铺底 + 时间轴对齐)
背景(2026-10-02 实测确认):
· 可灵生成的镜头**没有音轨**(payload 无音频参数)
· 分镜的 Shot.text(台词)此前只拼进提示词,不产生任何声音
· 导演台出片链路(pipeline.render)没有音频环节 → 成片无声
本模块补齐「声音」:
按镜头时间轴,把台词逐句合成为一条完整音轨;每个镜头一段,段长严格等于
镜头时长(短则补静音、长则加速),因此天然对齐画面。
音色来源(优先级):
1. 演员表指定的 voice_id(CastMember.voice_id)
2. 按角色代号稳定分配的路人音色池(同一角色全片同一音色,不会听起来换人)
环境音来源:
分镜的 Shot.audio_cue(如"远处车流声")→ 与环境音库资产做关键词匹配 →
该镜头期间低音量循环铺底。
输出:一条 44.1kHz 立体声 wav(可直接交给 StoryboardPipeline.mux_final 的 voice_path)
"""
from __future__ import annotations
import os
import re
import subprocess
import sys
from pathlib import Path
from typing import Callable, Optional
_SCRIPTS = Path(__file__).resolve().parent.parent
if str(_SCRIPTS) not in sys.path:
sys.path.insert(0, str(_SCRIPTS))
from lib.logger import get_logger # noqa: E402
from lib.ffmpeg import FFMPEG # noqa: E402 # 统一 ffmpeg 定位(绝对路径)
log = get_logger("dubbing")
API_BASE_SR = 44100
DEFAULT_SFX_VOLUME = 0.85 # 音效默认音量(非语言发声为主,比环境音高)
DEFAULT_AMBIENCE_VOLUME = 0.15
DEFAULT_VOICE_VOLUME = 1.0
ProgressCB = Optional[Callable[[str, str, str, str], None]]
# ════════════════════════════════════════════════════════════
# 音色分配
# ════════════════════════════════════════════════════════════
# 路人音色池(未指定音色的角色按代号稳定分配;来自 config/cosyvoice_voices.yaml 的 pools)
FALLBACK_MALE = ["longze_v3", "longzhe_v3", "longanlang_v3"]
FALLBACK_FEMALE = ["longxing_v3", "longmiao_v3", "longanqin_v3"]
NARRATOR_VOICE = "longxiu_v3"
def _is_female(slot: str, note: str = "") -> bool:
"""粗判角色性别(用于选路人音色池):代号/备注里出现女/她/妈/姐/妹/婆 → 女声"""
t = f"{slot} {note}"
return bool(re.search(r"女|她|妈|姐|妹|婆|娘|太太|女士|girl|woman", t))
def resolve_voice_map(sb, cast_voice_map: dict | None = None) -> dict:
"""角色代号 → voice_id(确定性分配)
cast_voice_map: 演员表里显式指定的 {slot: voice_id}
未指定的角色 → 按代号 hash 从路人池稳定取一个(同名永远同音色)
"""
cast_voice_map = cast_voice_map or {}
out: dict[str, str] = {}
# ⚠️ 字段名是 global_anchors(不是 anchors)—— 写错会导致演员表里的 voice_id 读不到,
# 角色音色全部退化为"路人池按 hash 分配"(用户实测发现)
_ga = getattr(sb, "global_anchors", None)
_cast = []
if _ga is not None and hasattr(_ga, "effective_cast"):
try:
_cast = _ga.effective_cast() or []
except Exception:
_cast = []
for m in _cast:
slot = getattr(m, "slot", "") or ""
if not slot:
continue
vid = (cast_voice_map.get(slot) or getattr(m, "voice_id", "") or "").strip()
if not vid:
pool = FALLBACK_FEMALE if _is_female(slot, getattr(m, "note", "")) else FALLBACK_MALE
vid = pool[sum(ord(c) for c in slot) % len(pool)]
out[slot] = vid
# 演员表未覆盖的出场代号(导演写的)也要有音色
for shot in getattr(sb, "shots", []) or []:
for slot in (getattr(shot, "cast_slots", None) or []):
if slot and slot not in out:
pool = FALLBACK_FEMALE if _is_female(slot) else FALLBACK_MALE
out[slot] = pool[sum(ord(c) for c in slot) % len(pool)]
return out
def pick_voice_for_shot(shot, voice_map: dict, narrator: str = NARRATOR_VOICE) -> str:
"""该镜头台词用谁的音色:出场人物第一顺位;无出场 → 旁白音色"""
slots = getattr(shot, "cast_slots", None) or []
for s in slots:
if s in voice_map:
return voice_map[s]
if slots:
# 演员表里没有的代号 → 用其自身 hash 分配
pool = FALLBACK_FEMALE if _is_female(slots[0]) else FALLBACK_MALE
return pool[sum(ord(c) for c in slots[0]) % len(pool)]
return narrator
# ════════════════════════════════════════════════════════════
# 音频工具
# ════════════════════════════════════════════════════════════
def _run(cmd: list[str], timeout: int = 600) -> subprocess.CompletedProcess:
return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
def probe_audio_duration(path: str) -> float:
"""音频/视频时长(秒);用 ffmpeg -i 解析(本机无真 ffprobe,见 lib/ffmpeg.py)"""
from lib.ffmpeg import probe_duration
d = probe_duration(path)
return float(d or 0.0)
def synthesize_line(text: str, voice_id: str, out_path: str,
api_key: str = "", speed: float = 1.0) -> str:
"""单句 TTS(阿里 CosyVoice)"""
from voice_synthesizer.aliyun import synthesize
os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True)
synthesize(text, out_path, api_key=api_key, voice_id=voice_id, speed=speed)
return out_path
def make_silence(duration_sec: float, dst: str) -> str:
"""生成指定时长的静音段(44.1k 立体声 wav)"""
r = _run([FFMPEG, "-y", "-f", "lavfi",
"-i", f"anullsrc=channel_layout=stereo:sample_rate={API_BASE_SR}",
"-t", f"{max(0.05, duration_sec):.3f}",
"-c:a", "pcm_s16le", dst], timeout=120)
if r.returncode != 0:
raise RuntimeError(f"生成静音失败: {r.stderr[-300:]}")
return dst
def fit_audio_to_duration(src: str, dst: str, target_sec: float,
max_speedup: float = 1.6) -> dict:
"""把音频适配到目标时长
· 短于目标 → 补静音(apad),保持原速
· 长于目标 → 加速(atempo,最多 max_speedup 倍);仍超长则截断
返回 {out, src_dur, target_dur, action}
"""
src_dur = probe_audio_duration(src)
target = max(0.3, float(target_sec))
action = "keep"
afilters: list[str] = []
if src_dur <= 0:
# 探不到时长 → 直接按目标截断 + 补静音,交给 ffmpeg
afilters = ["apad"]
action = "unknown-duration"
elif src_dur > target * 1.02:
ratio = src_dur / target
if ratio <= max_speedup:
afilters = [f"atempo={ratio:.4f}"]
action = f"加速×{ratio:.2f}"
else:
afilters = [f"atempo={max_speedup:.4f}", "apad"]
action = f"加速×{max_speedup:.2f}+截断"
else:
afilters = ["apad"]
action = "补静音"
af = ",".join(afilters) if afilters else "anull"
r = _run([FFMPEG, "-y", "-i", src, "-af", af,
"-t", f"{target:.3f}", "-ar", str(API_BASE_SR), "-ac", "2",
"-c:a", "pcm_s16le", dst], timeout=300)
if r.returncode != 0:
raise RuntimeError(f"音频适配失败: {r.stderr[-300:]}")
return {"out": dst, "src_dur": round(src_dur, 2), "target_dur": round(target, 2), "action": action}
def mix_segment(voice_path: str, ambience_path: str, duration_sec: float,
dst: str, ambience_volume: float = DEFAULT_AMBIENCE_VOLUME,
sfx_items: list | None = None) -> str:
"""把「人声 + 环境音(循环)+ 音效(按时点插入)」混成一个镜头段,严格等于目标时长
sfx_items: [(音效文件路径, 插入秒数, 音量)] —— 非语言发声(惨叫/闷哼/关门声…)
"""
dur = max(0.3, float(duration_sec))
sfx_items = [x for x in (sfx_items or [])
if x and x[0] and Path(x[0]).exists()]
has_v = bool(voice_path and Path(voice_path).exists())
has_a = bool(ambience_path and Path(ambience_path).exists())
if not (has_v or has_a or sfx_items):
return make_silence(dur, dst)
inputs: list[str] = []
if has_v:
inputs += ["-i", voice_path]
if has_a:
inputs += ["-stream_loop", "-1", "-i", ambience_path]
for f, _at, _vol in sfx_items:
inputs += ["-i", f]
parts: list[str] = []
labels: list[str] = []
idx = 0
if has_v:
parts.append(f"[{idx}:a]volume={DEFAULT_VOICE_VOLUME},apad[v]")
labels.append("[v]"); idx += 1
if has_a:
parts.append(f"[{idx}:a]volume={ambience_volume},atrim=0:{dur:.3f},asetpts=N/SR/TB[a]")
labels.append("[a]"); idx += 1
for k, (f, at, vol) in enumerate(sfx_items):
ms = int(max(0.0, min(float(at), dur - 0.05)) * 1000)
parts.append(f"[{idx}:a]volume={vol},adelay={ms}|{ms}[s{k}]")
labels.append(f"[s{k}]"); idx += 1
if len(labels) == 1:
parts.append(f"{labels[0]}anull[out]")
else:
parts.append(f"{''.join(labels)}amix=inputs={len(labels)}:duration=first:normalize=0[out]")
r = _run([FFMPEG, "-y", *inputs, "-filter_complex", ";".join(parts),
"-map", "[out]", "-t", f"{dur:.3f}",
"-ar", str(API_BASE_SR), "-ac", "2", "-c:a", "pcm_s16le", dst], timeout=300)
if r.returncode != 0:
raise RuntimeError(f"段混音失败: {r.stderr[-300:]}")
return dst
def concat_audio(seg_paths: list[str], out_path: str) -> str:
"""按顺序拼接音频段(同规格 wav → concat demuxer)"""
if not seg_paths:
raise ValueError("没有音频段可拼接")
lst = Path(out_path).with_suffix(".txt")
lst.write_text("".join(f"file '{Path(p).resolve()}'\n" for p in seg_paths), encoding="utf-8")
r = _run([FFMPEG, "-y", "-f", "concat", "-safe", "0", "-i", str(lst),
"-c:a", "pcm_s16le", "-ar", str(API_BASE_SR), "-ac", "2", out_path], timeout=600)
lst.unlink(missing_ok=True)
if r.returncode != 0:
raise RuntimeError(f"音频拼接失败: {r.stderr[-300:]}")
return out_path
# ════════════════════════════════════════════════════════════
# 环境音匹配(分镜 audio_cue ↔ 环境音库)
# ════════════════════════════════════════════════════════════
def load_ambience_assets() -> list[dict]:
"""读取环境音库资产(含本地文件路径)"""
try:
from assets_manager import AssetStore
st = AssetStore("ambience")
out = []
for a in st.list():
f = ""
for slot in ("audio", "file", "sample"):
v = a.get(slot) or ""
if v:
try:
f = str(Path(st.kind_dir(a["id"])) / Path(v).name)
except Exception:
f = ""
break
if not f or not Path(f).exists():
# 退一步:目录下第一个音频文件
try:
d = Path(st.kind_dir(a["id"]))
cands = [p for p in d.glob("*") if p.suffix.lower() in (".mp3", ".wav", ".m4a", ".aac", ".ogg")]
f = str(cands[0]) if cands else ""
except Exception:
f = ""
a["_file"] = f
out.append(a)
return out
except Exception as e:
log.warning("环境音库读取失败: %s", e)
return []
def load_sfx_assets() -> list[dict]:
"""读取音效库资产(含本地文件路径)—— 结构同环境音库"""
try:
from assets_manager import AssetStore
st = AssetStore("sfx")
out = []
for a in st.list():
f = ""
for slot in ("audio", "file", "sample"):
v = a.get(slot) or ""
if v:
try:
f = str(Path(st.kind_dir(a["id"])) / Path(v).name)
except Exception:
f = ""
break
if not f or not Path(f).exists():
try:
d = Path(st.kind_dir(a["id"]))
cands = [p for p in d.glob("*")
if p.suffix.lower() in (".mp3", ".wav", ".m4a", ".aac", ".ogg")]
f = str(cands[0]) if cands else ""
except Exception:
f = ""
a["_file"] = f
out.append(a)
return out
except Exception as e:
log.warning("音效库读取失败: %s", e)
return []
def match_sfx(desc: str, assets: list[dict]) -> str:
"""用音效描述匹配音效资产(名称/标签最长子串命中)→ 文件路径
例:desc「痛苦的闷哼」→ 命中音效「闷哼」/「痛呼」
"""
if not desc or not assets:
return ""
d = str(desc)
best, best_len = "", 0
for a in assets:
f = a.get("_file") or ""
if not f:
continue
for key in [a.get("name", "")] + list(a.get("tags") or []):
k = str(key or "").strip()
if len(k) >= 2 and (k in d or d in k) and len(k) > best_len:
best, best_len = f, len(k)
return best
def match_ambience(cue: str, assets: list[dict]) -> str:
"""用 audio_cue 文本匹配环境音资产(名称/标签最长子串命中)→ 文件路径"""
if not cue or not assets:
return ""
best, best_len = "", 0
for a in assets:
f = a.get("_file") or ""
if not f:
continue
for key in [a.get("name", "")] + list(a.get("tags") or []):
k = str(key or "").strip()
if len(k) >= 2 and k in cue and len(k) > best_len:
best, best_len = f, len(k)
return best
# ════════════════════════════════════════════════════════════
# 主入口
# ════════════════════════════════════════════════════════════
def pre_synthesize(sb, work_dir: str, api_key: str = "",
cast_voice_map: dict | None = None,
min_dur: float = 2.0, max_dur: float = 10.0,
pad: float = 0.4, progress_cb: ProgressCB = None) -> dict:
"""【音频先行】逐镜合成台词 → 按音频**真实时长**更新镜头时长
为什么:画面时长若拍脑袋定,台词长了就得加速/截断 → 口型节奏必然对不上。
音频先行 = 让画面跟着声音走。
· 有台词的镜头:TTS → 探测时长 → shot.duration = clamp(音频时长 + 气口, min, max)
· 无台词镜头:保持原时长
· 音频存到 {work_dir}/{shot_id}.mp3 → 后续混流**直接复用,不重复 TTS**
返回 {updated, shots:{shot_id:{audio,audio_dur,old_dur,new_dur,voice_id}}, notes}
"""
work = Path(work_dir)
work.mkdir(parents=True, exist_ok=True)
from lib.config import load_config
cfg = load_config()
api_key = api_key or (cfg.get("aliyun", {}) or {}).get("api_key", "")
if not api_key:
raise RuntimeError("音频先行需要百炼 API Key(TTS)")
voice_map = resolve_voice_map(sb, cast_voice_map)
out: dict = {}
notes: list = []
updated = 0
shots = list(getattr(sb, "shots", []) or [])
for idx, shot in enumerate(shots, 1):
sid = getattr(shot, "shot_id", f"shot_{idx:02d}")
text = (getattr(shot, "text", "") or "").strip()
if not text:
continue
vid = pick_voice_for_shot(shot, voice_map)
mp3 = work / f"{sid}.mp3"
if not (mp3.exists() and mp3.stat().st_size > 1000): # 已有则复用
synthesize_line(text, vid, str(mp3), api_key=api_key)
adur = probe_audio_duration(str(mp3))
if adur <= 0:
notes.append(f"{sid}: 音频时长探测失败,保持原时长")
continue
old = float(getattr(shot, "duration", 5) or 5)
new = max(min_dur, min(max_dur, round(adur + pad, 2)))
shot.duration = new
if abs(new - old) > 0.01:
updated += 1
notes.append(f"{sid}: {old}s → {new}s(音频 {adur:.2f}s · {vid})")
out[sid] = {"audio": str(mp3), "audio_dur": round(adur, 2),
"old_dur": old, "new_dur": new, "voice_id": vid,
"has_cast": bool([s for s in (getattr(shot, "cast_slots", None) or []) if s])}
if progress_cb:
progress_cb("tts", sid, "done", f"{adur:.1f}s → 镜头 {new}s")
log.info(" 🎙 音频先行:%d/%d 个镜头时长按台词音频调整", updated, len(shots))
return {"updated": updated, "shots": out, "notes": notes, "voice_map": voice_map}
def build_voice_track(sb, out_path: str, work_dir: str,
api_key: str = "",
cast_voice_map: dict | None = None,
use_ambience: bool = True,
ambience_volume: float = DEFAULT_AMBIENCE_VOLUME,
progress_cb: ProgressCB = None,
reuse_dir: str = "") -> dict:
"""按分镜时间轴合成完整音轨
返回 {output, segments, voice_seconds, ambience_hits, voices:[...], notes:[...]}
"""
work = Path(work_dir)
work.mkdir(parents=True, exist_ok=True)
if str(_SCRIPTS) not in sys.path:
sys.path.insert(0, str(_SCRIPTS))
from lib.config import load_config
cfg = load_config()
api_key = api_key or (cfg.get("aliyun", {}) or {}).get("api_key", "")
if not api_key:
raise RuntimeError("未配置百炼 API Key(TTS 需要)")
voice_map = resolve_voice_map(sb, cast_voice_map)
amb_assets = load_ambience_assets() if use_ambience else []
sfx_assets = load_sfx_assets() # 音效库(非语言发声:惨叫/闷哼/关门…)
log.info("配音: 音色映射 %s | 环境音库 %d 条 | 音效库 %d 条",
voice_map, len(amb_assets), len(sfx_assets))
segs: list[str] = []
notes: list[str] = []
voice_seconds = 0.0
amb_hits = 0
sfx_hits = 0
# ★ 分轨(用户 2026-10-03 提出的声音模型):
# · 台词轨(dialogue):有出场人物的镜头 → 与画面强绑定(还要用于对口型)
# · 旁白轨(narration):无出场人物 → 独立一条声轨,允许与画面有轻微差异
dialogue_segs: list[str] = []
narration_segs: list[str] = []
shots = list(getattr(sb, "shots", []) or [])
for idx, shot in enumerate(shots, 1):
sid = getattr(shot, "shot_id", f"shot_{idx:02d}")
dur = float(getattr(shot, "duration", 3) or 3)
text = (getattr(shot, "text", "") or "").strip()
if progress_cb:
progress_cb("dub", sid, "running", f"{idx}/{len(shots)}")
voice_path = ""
if text:
vid = pick_voice_for_shot(shot, voice_map)
# ★ 优先复用「音频先行」已合成的音频(不重复 TTS = 不重复计费)
_pre = Path(reuse_dir) / f"{sid}.mp3" if reuse_dir else None
if _pre and _pre.exists() and _pre.stat().st_size > 1000:
raw = _pre
notes.append(f"{sid}: 复用先行音频")
else:
raw = work / f"tts_{idx:02d}.mp3"
synthesize_line(text, vid, str(raw), api_key=api_key)
try:
fitted = work / f"voice_{idx:02d}.wav"
info = fit_audio_to_duration(str(raw), str(fitted), dur)
voice_path = str(fitted)
voice_seconds += min(info["src_dur"], dur)
notes.append(f"{sid}: {vid} · {info['action']}({len(text)}字)")
except Exception as e:
log.warning(" ⚠️ %s 台词合成失败: %s", sid, e)
notes.append(f"{sid}: 台词合成失败({str(e)[:40]})")
voice_path = ""
# 环境音(该镜头的 audio_cue)
amb_path = ""
if amb_assets:
_a = getattr(shot, "audio", None)
cue = ""
if _a is not None:
cue = (_a.get("audio_cue", "") if isinstance(_a, dict)
else getattr(_a, "audio_cue", "")) or ""
cue = str(cue).strip()
if cue:
amb_path = match_ambience(cue, amb_assets)
if amb_path:
amb_hits += 1
notes.append(f"{sid}: 环境音「{cue}」匹配 ✓")
# 音效(非语言发声):按 desc 匹配素材,按 at_second 插入该镜头内
sfx_items: list = []
if sfx_assets:
_a = getattr(shot, "audio", None)
raw_sfx = []
if _a is not None:
raw_sfx = ((_a.get("sfx", []) if isinstance(_a, dict)
else getattr(_a, "sfx", [])) or [])
if isinstance(raw_sfx, (str, dict)):
raw_sfx = [raw_sfx]
for s in raw_sfx:
if isinstance(s, dict):
desc = str(s.get("desc") or s.get("name") or "").strip()
at = float(s.get("at_second") or 0)
vol = float(s.get("volume") or DEFAULT_SFX_VOLUME)
else:
desc, at, vol = str(s or "").strip(), 0.0, DEFAULT_SFX_VOLUME
if not desc:
continue
f = match_sfx(desc, sfx_assets)
if f:
sfx_items.append((f, at, vol))
sfx_hits += 1
notes.append(f"{sid}: 音效「{desc}」@{at}s ✓")
else:
notes.append(f"{sid}: 音效「{desc}」未匹配到素材(音效库为空?)")
# ★ 分轨:判断这一段是「角色台词」还是「旁白」
# 台词(有出场人物)→ 主轨;旁白(无出场人物)→ 单独轨(不重复放进主轨)
_slots = [x for x in (getattr(shot, "cast_slots", None) or []) if x]
is_narration = bool(text) and not _slots
voice_for_main = "" if is_narration else voice_path
seg = work / f"seg_{idx:02d}.wav"
mix_segment(voice_for_main, amb_path, dur, str(seg),
ambience_volume=ambience_volume, sfx_items=sfx_items)
segs.append(str(seg))
# 旁白轨:该镜头是旁白 → 只放人声;否则留空(拼接时补等长静音)
if is_narration:
narr_seg = work / f"narr_{idx:02d}.wav"
try:
mix_segment(voice_path, "", dur, str(narr_seg))
narration_segs.append(str(narr_seg))
notes.append(f"{sid}: 旁白(单独轨)")
except Exception as e:
log.warning(" ⚠️ %s 旁白轨生成失败: %s", sid, e)
narration_segs.append("")
else:
narration_segs.append("")
if progress_cb:
progress_cb("dub", sid, "done",
"旁白" if is_narration else ("角色台词" if text else "无台词"))
concat_audio(segs, out_path)
# ★ 旁白轨(独立输出):无旁白的镜头补等长静音,保证与主轨时间轴对齐
narr_out = ""
if any(narration_segs):
narr_parts = []
for i, (n, shot) in enumerate(zip(narration_segs, shots), 1):
if n:
narr_parts.append(n)
else:
dur_i = float(getattr(shot, "duration", 3) or 3)
sil = work / f"narr_sil_{i:02d}.wav"
try:
if not sil.exists():
make_silence(dur_i, str(sil))
narr_parts.append(str(sil))
except Exception:
pass
if narr_parts:
narr_out = str(Path(out_path).with_name("narration_track.wav"))
try:
concat_audio(narr_parts, narr_out)
log.info(" 🎙 旁白轨单独输出: %s(%d 段)",
Path(narr_out).name, len([x for x in narration_segs if x]))
except Exception as e:
log.warning(" ⚠️ 旁白轨拼接失败: %s", e)
narr_out = ""
total = sum(float(getattr(s, "duration", 3) or 3) for s in shots)
log.info(" 🎙 配音完成: %s(%d 段 / %.1fs 音轨)", Path(out_path).name, len(segs), total)
return {"output": out_path, "narration": narr_out, "segments": len(segs),
"voice_seconds": round(voice_seconds, 2), "ambience_hits": amb_hits,
"sfx_hits": sfx_hits,
"narration_segments": len([x for x in narration_segs if x]),
"total_seconds": round(total, 2), "voices": voice_map, "notes": notes}
# ── CLI 自测:从已保存分镜生成音轨 ──
if __name__ == "__main__":
import argparse, json
ap = argparse.ArgumentParser(description="从分镜生成配音音轨(有 TTS 费用)")
ap.add_argument("--storyboard", required=True, help="分镜 JSON 路径或 project_id")
ap.add_argument("--output", default="", help="输出 wav 路径")
ap.add_argument("--no-ambience", action="store_true", help="不使用环境音")
args = ap.parse_args()
p = Path(args.storyboard)
if not p.exists():
p = Path.home() / "workbuddy-agent-os/agent-local/runtime/ave/storyboards" / f"{args.storyboard}.json"
data = json.loads(p.read_text(encoding="utf-8"))
from schemas import Storyboard
sb = Storyboard.from_dict(data)
out = args.output or str(Path.home() / "workbuddy-agent-os/agent-local/runtime/ave/dub_test.wav")
r = build_voice_track(sb, out, str(Path(out).parent / "dub_work"),
use_ambience=not args.no_ambience)
print(json.dumps({k: v for k, v in r.items() if k != "notes"}, ensure_ascii=False, indent=1))
for n in r["notes"]:
print(" ·", n)
@@ -0,0 +1,699 @@
"""
AVE 06_composer — FFmpeg 合成模块
职责:
1. 音频混音: 人声 + BGM → 混合音频
2. 视频合成: 素材片段拼接 + 画面过渡
3. 字幕叠加: ASS 格式中文字幕(带重音高亮)
4. 最终编码: 输出 final.mp4
5. 分段渲染: 3分钟以上视频自动分段
依赖:
- ffmpeg (需安装)
- Python: ffmpeg-python
"""
import sys, os; sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import asyncio
import json
import os
import subprocess
import tempfile
from pathlib import Path
from typing import Optional
from lib.logger import get_logger
from lib.ffmpeg import FFMPEG # noqa: E402 (FFmpeg 绝对路径)
logger = get_logger("composer")
# ── 常量 ──────────────────────────────────────────────────
DEFAULT_RESOLUTION = "1080x1920" # 竖屏
DEFAULT_FPS = 30
DEFAULT_BITRATE = "8M"
SEGMENT_DURATION_MAX = 180 # 3分钟分段
# ── 公共 API ──────────────────────────────────────────────
def mix_audio(
voice_path: str,
bgm_path: Optional[str] = None,
output_path: str = "mixed_audio.wav",
voice_volume: float = 1.0,
bgm_volume: float = 0.3,
) -> str:
"""
混音: 人声 + BGM → 混合音频
- voice_path: 人声 WAV 路径
- bgm_path: BGM 路径 (None 则只输出人声)
- voice_volume: 人声音量倍率
- bgm_volume: BGM 音量倍率
- 返回: 混合音频路径
"""
if not os.path.exists(voice_path):
raise FileNotFoundError(f"人声文件不存在: {voice_path}")
if bgm_path and not os.path.exists(bgm_path):
logger.warning(f"BGM 文件不存在,跳过背景音乐: {bgm_path}")
bgm_path = None
if not bgm_path:
# 无人声时,直接复制人声
subprocess.run(
[FFMPEG, "-y", "-i", voice_path, "-acodec", "pcm_s16le", output_path],
capture_output=True, check=True
)
logger.info(f"音频已输出(无BGM): {output_path}")
return output_path
# 双轨混音
cmd = [
FFMPEG, "-y",
"-i", voice_path,
"-i", bgm_path,
"-filter_complex",
f"[0:a]volume={voice_volume}[v];[1:a]volume={bgm_volume}[b];[v][b]amix=inputs=2:duration=first[a]",
"-map", "[a]",
"-acodec", "pcm_s16le",
output_path,
]
subprocess.run(cmd, capture_output=True, check=True)
logger.info(f"混音完成: {output_path}")
return output_path
def duck_bgm(
voice_path: str,
bgm_path: str,
output_path: str = "ducked_bgm.wav",
low_volume: float = 0.15,
high_volume: float = 0.50,
fade_ms: int = 300,
word_timestamps: list | None = None,
) -> str:
"""
BGM 音量避让 (Audio Ducking), 基于字级时间戳
说话时 BGM 压低 (low_volume), 间隙时恢复 (high_volume)。
使用 word_timestamps 精确控制每个字的避让区间,
比 silencedetect 更准确 (CosyVoice 连续语音无停顿).
参数:
word_timestamps: [{begin_time, end_time}, ...] 单位毫秒
来自 synthesize_with_timestamps()
"""
if not os.path.exists(voice_path):
raise FileNotFoundError(f"人声文件不存在: {voice_path}")
if not os.path.exists(bgm_path):
raise FileNotFoundError(f"BGM 文件不存在: {bgm_path}")
# 如果没有时间戳, 回退到固定音量
if not word_timestamps:
logger.info(" 无时间戳, 使用固定音量 0.25")
subprocess.run([
FFMPEG, "-y", "-i", bgm_path,
"-af", f"volume={high_volume}",
"-acodec", "pcm_s16le", output_path,
], capture_output=True, check=True, timeout=30)
return output_path
duration = _get_media_duration(voice_path)
total_ms = duration * 1000
fade_ms_f = fade_ms / 1000.0
logger.info(f"BGM 避让: {low_volume}→{high_volume}, 基于 {len(word_timestamps)} 个字")
# 构建音量区间: [(start_sec, end_sec, volume), ...]
# 每个字的区间: 字开始前 fade_ms 开始压低, 字结束后 fade_ms 恢复
segments = []
# 先确定所有活跃区间 (字的时间范围 + 过渡)
active_zones = [] # [(start, end), ...] 单位为秒
for w in word_timestamps:
w_start = max(0, (w["begin_time"] - 50) / 1000.0) # 字前 50ms
w_end = min(duration, (w["end_time"] + 50) / 1000.0) # 字后 50ms
active_zones.append((w_start, w_end))
# 合并重叠的活跃区间
active_zones.sort()
merged = []
for start, end in active_zones:
if merged and start <= merged[-1][1] + 0.05: # 50ms 内视为连续
merged[-1] = (merged[-1][0], max(merged[-1][1], end))
else:
merged.append((start, end))
# 构建音量分段: 活跃期低音量, 间隙期高音量
prev_end = 0.0
for a_start, a_end in merged:
# 间隙: prev_end → a_start (高音量)
if a_start > prev_end + 0.05:
gap_mid = (prev_end + a_start) / 2
segments.append((prev_end, gap_mid, high_volume))
segments.append((gap_mid, a_start, high_volume))
# 说话: a_start → a_end (低音量, 带过渡)
segments.append((a_start, a_end, low_volume))
prev_end = a_end
# 最后间隙
if duration > prev_end + 0.05:
segments.append((prev_end, duration, high_volume))
if not segments:
logger.info(" 无分段, 使用固定音量")
subprocess.run([
FFMPEG, "-y", "-i", bgm_path,
"-af", f"volume={high_volume}",
"-acodec", "pcm_s16le", output_path,
], capture_output=True, check=True, timeout=30)
return output_path
# 渲染每段再拼接
import tempfile, shutil
seg_dir = tempfile.mkdtemp(prefix="ave_duck_")
seg_wavs = []
for idx, (st, en, vol) in enumerate(segments):
dur = en - st
if dur < 0.05:
continue
seg_out = os.path.join(seg_dir, f"seg_{idx:03d}.wav")
fade_dur = min(fade_ms_f, dur / 3)
subprocess.run([
FFMPEG, "-y",
"-ss", str(st), "-i", bgm_path,
"-t", str(dur),
"-af", f"volume={vol},afade=t=in:d={fade_dur},afade=t=out:st={dur-fade_dur}:d={fade_dur}",
"-acodec", "pcm_s16le", seg_out,
], capture_output=True, timeout=60, check=True)
seg_wavs.append(seg_out)
# 拼接
concat_file = _create_concat_list(seg_wavs)
if len(seg_wavs) == 1:
subprocess.run([FFMPEG, "-y", "-i", seg_wavs[0], "-acodec", "pcm_s16le", output_path],
capture_output=True, check=True)
else:
subprocess.run([
FFMPEG, "-y",
"-f", "concat", "-safe", "0", "-i", concat_file,
"-acodec", "pcm_s16le", output_path,
], capture_output=True, check=True, timeout=60)
shutil.rmtree(seg_dir, ignore_errors=True)
logger.info(f"BGM 避让完成: {output_path} ({len(seg_wavs)} 段)")
return output_path
def image_to_clip(
input_path: str,
output_path: str,
duration: float = 5.0,
resolution: str = DEFAULT_RESOLUTION,
ken_burns: bool = True,
) -> str:
"""
将静态图片转换为视频分镜(带 Ken Burns 动效)
处理横图→竖屏转换(如 1248×832→1080×1920),用于 AVE 流水线中
图片生成阶段→合成阶段之间的转换步骤。
策略:先按目标高度等比缩放填满画面,然后 crop 到目标宽度,
再添加 Ken Burns 式平滑平移动效。
"""
target_w, target_h = [int(x) for x in resolution.split("x")]
if ken_burns:
# 使用 cos 实现平滑左右平移: 从右到左的平滑摆动
filter_str = (
f"scale='max({target_w},iw*{target_h}/ih)':{target_h}:flags=lanczos,"
f"crop={target_w}:{target_h}:"
f"'(iw-{target_w})*(0.5-0.45*cos(PI*t/{duration}))':'0',"
f"setsar=1,fps={DEFAULT_FPS}"
)
else:
# 无动效:居中裁剪
filter_str = (
f"scale='max({target_w},iw*{target_h}/ih)':{target_h}:flags=lanczos,"
f"crop={target_w}:{target_h}:'(iw-{target_w})/2':'(ih-{target_h})/2',"
f"setsar=1,fps={DEFAULT_FPS}"
)
cmd = [
FFMPEG, "-y",
"-loop", "1", "-t", str(duration),
"-i", input_path,
"-vf", filter_str,
"-c:v", "libx264", "-preset", "medium", "-crf", "23",
"-pix_fmt", "yuv420p", "-an",
output_path,
]
subprocess.run(cmd, capture_output=True, timeout=300, check=True)
logger.info(f"图片→视频分镜: {os.path.basename(input_path)} → {os.path.basename(output_path)} ({duration}s)")
return output_path
def _apply_ken_burns(input_path: str, output_path: str, resolution: str = DEFAULT_RESOLUTION) -> str:
"""
对视频素材施加随机 Ken Burns 效果(缩放+平移),让画面动起来。
策略:先获取原始尺寸,判断横竖屏,选择合适的缩放+裁切方案。
竖屏素材(720x1280等):放大后以宽度为基准裁切到1080。
"""
import random
duration = _get_media_duration(input_path)
if duration < 1.0:
duration = 5.0
target_w, target_h = [int(x) for x in resolution.split("x")]
# 获取原视频尺寸
try:
from lib.ffmpeg import probe_info
_info = probe_info(input_path)
src_w, src_h = _info.get("width") or target_w, _info.get("height") or target_h
except Exception:
src_w, src_h = target_w, target_h
# 判断方向
is_portrait = src_h > src_w # 竖屏
if is_portrait and src_w >= target_w:
# 竖屏且宽度够 → 只用缩放覆盖高度,平移只在竖直方向
zoom = random.uniform(1.10, 1.25)
d = f"max(t,0.001)/{max(duration,1)}"
trajectory = random.choice(["center_zoom", "pan_up", "pan_down"])
if trajectory == "center_zoom":
x, y = f"(iw-{target_w})/2", f"(ih-{target_h})/2"
elif trajectory == "pan_up":
x, y = f"(iw-{target_w})/2", f"(ih-{target_h})*(1-{d})"
else: # pan_down
x, y = f"(iw-{target_w})/2", f"(ih-{target_h})*{d}"
kb_type = trajectory
else:
# 横屏或小尺寸 → 仅缩放填充,不做平移(避免负值)
zoom = max(target_w / max(src_w, 1), target_h / max(src_h, 1)) * 1.05
x, y = f"(iw-{target_w})/2", f"(ih-{target_h})/2"
kb_type = "center"
filter_str = (
f"scale=iw*{zoom}:ih*{zoom}:flags=lanczos,"
f"crop={target_w}:{target_h}:{x}:{y},"
f"setsar=1,fps=30"
)
cmd = [
FFMPEG, "-y", "-i", input_path,
"-vf", filter_str,
"-c:v", "libx264", "-preset", "ultrafast", "-crf", "25",
"-an", output_path,
]
subprocess.run(cmd, capture_output=True, timeout=300, check=True)
logger.debug(f"Ken Burns [{kb_type} @ {zoom:.2f}x] → {output_path}")
return output_path
def compose_video(
material_clips: list[str],
audio_path: str,
output_path: str = "final.mp4",
resolution: str = DEFAULT_RESOLUTION,
fps: int = DEFAULT_FPS,
transitions: list[str] | None = None,
subtitles_path: str | None = None,
total_duration: float | None = None,
ken_burns: bool = True,
) -> str:
"""
视频合成: 素材片段 + 音频 + 字幕 → final.mp4
- material_clips: 素材视频文件路径列表
- audio_path: 混音音频路径
- output_path: 输出文件路径
- transitions: 每段之间的过渡效果 (fade/cut/dissolve),长度=clips数-1
- subtitles_path: ASS/SRT 字幕文件路径 (可选)
- total_duration: 视频总时长(秒),用于裁剪素材匹配音频
- ken_burns: 是否对每个素材施加随机缩放/平移动效(默认开启)
**素材时长自动匹配策略**:
如果所有素材总时长 < 音频时长 → 末尾慢放/循环最后一段
如果素材总时长 > 音频时长 → 裁剪素材到恰好匹配
"""
if not material_clips:
raise ValueError("素材列表为空")
if not os.path.exists(audio_path):
raise FileNotFoundError(f"音频文件不存在: {audio_path}")
# 1. 可选择对素材施加 Ken Burns 动效(逐素材预处理)
final_clips = material_clips
if ken_burns:
import tempfile, shutil
ken_dir = tempfile.mkdtemp(prefix="ave_kenburns_")
processed = []
for idx, clip in enumerate(material_clips):
out = os.path.join(ken_dir, f"kb_{idx:03d}.mp4")
_apply_ken_burns(clip, out, resolution)
processed.append(out)
final_clips = processed
logger.info(f"Ken Burns 已应用于 {len(final_clips)} 个素材")
# 2. 创建素材拼接文件列表
concat_file = _create_concat_list(final_clips)
# 3. 获取音频总时长
audio_duration = _get_media_duration(audio_path)
# 4. 构建 FFmpeg 命令
cmd = [
FFMPEG, "-y",
"-f", "concat", "-safe", "0", "-i", concat_file,
"-i", audio_path,
]
filters = []
# 5. 如果素材总时长 > 音频时长,裁剪
materials_duration = sum(_get_media_duration(c) for c in final_clips)
output_duration = audio_duration
# 6. 视频流处理(Ken Burns 已预处理,这里只做字幕叠加)
filters.append(f"[0:v]setpts=PTS-STARTPTS,fps={fps}[v]")
filters.append("[1:a]volume=1.0[a]")
# 7. 字幕叠加
if subtitles_path and os.path.exists(subtitles_path):
filters.append(f"[v]subtitles={subtitles_path}[vo]")
map_v = "[vo]"
else:
map_v = "[v]"
filter_complex = ";".join(filters)
cmd.extend(["-filter_complex", filter_complex])
cmd.extend(["-map", map_v, "-map", "[a]"])
# 8. 时长匹配
logger.info(f" 素材总时长: {materials_duration:.1f}s, 音频时长: {audio_duration:.1f}s")
if materials_duration < audio_duration:
logger.warning(f"素材不足,尾部会黑屏补齐")
cmd.extend(["-shortest"])
# 9. 编码参数
cmd.extend([
"-c:v", "libx264",
"-preset", "medium",
"-b:v", DEFAULT_BITRATE,
"-c:a", "aac",
"-b:a", "192k",
"-pix_fmt", "yuv420p",
"-movflags", "+faststart",
output_path,
])
try:
subprocess.run(cmd, capture_output=True, timeout=600)
logger.info(f"视频合成完成: {output_path}")
except subprocess.TimeoutExpired:
logger.error("渲染超时(>600s),建议分段渲染")
raise
except subprocess.CalledProcessError as e:
logger.error(f"FFmpeg 执行失败: {e.stderr.decode(errors='replace')[:500]}")
raise
# 清理临时文件
if ken_burns:
shutil.rmtree(ken_dir, ignore_errors=True)
return output_path
def create_subtitles(
segments: list[dict],
output_path: str = "subtitles.ass",
resolution: tuple = (1080, 1920),
) -> str:
"""
从分段信息生成 ASS 格式字幕
segments 格式:
[{"text": "...", "start_sec": 0.0, "end_sec": 8.0, "emotion": "..."}]
"""
width, height = resolution
# 字幕位置: 由下往上 20% (竖屏 1080x1920)
margin_v = int(height * 0.2)
font_size = max(48, int(height * 0.044)) # 1920 → ~84px
lines = [
"[Script Info]",
"ScriptType: v4.00+",
f"PlayResX: {width}",
f"PlayResY: {height}",
"ScaledBorderAndShadow: yes",
"",
"[V4+ Styles]",
"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
f"Style: Default,Noto Sans SC,{font_size},&H00FFFFFF,&H0000FFFF,&H00000000,&H80000000,-1,0,0,0,100,100,0,0,1,3,1,2,100,100,{margin_v},1",
"",
"[Events]",
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text",
]
for seg in segments:
start = _sec_to_ass_time(seg.get("start_sec", 0))
end = _sec_to_ass_time(seg.get("end_sec", 8))
text = seg.get("text", "").replace("\n", "\\N")
lines.append(f"Dialogue: 0,{start},{end},Default,,0,0,0,,{text}")
with open(output_path, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
logger.info(f"字幕生成: {output_path} ({len(segments)} 段)")
return output_path
def segment_render(
material_clips: list[str],
audio_path: str,
output_dir: str = ".",
segment_duration: int = SEGMENT_DURATION_MAX,
subtitles_path: str | None = None,
resolution: str = DEFAULT_RESOLUTION,
) -> list[str]:
"""
分段渲染: 长视频自动分段 → 分别渲染 → 返回分段文件列表
后续由 FFmpeg concat 拼接
"""
audio_duration = _get_media_duration(audio_path)
if audio_duration <= segment_duration:
# 无需分段
out = os.path.join(output_dir, "segment_000.mp4")
compose_video(material_clips, audio_path, out,
subtitles_path=subtitles_path,
resolution=resolution)
return [out]
# 需要分段
segments = []
for i in range(0, int(audio_duration), segment_duration):
start = i
end = min(i + segment_duration, audio_duration)
out = os.path.join(output_dir, f"segment_{i:03d}.mp4")
_render_segment(material_clips, audio_path, start, end, out,
subtitles_path=subtitles_path,
resolution=resolution)
segments.append(out)
logger.info(f"分段渲染完成: {len(segments)} 段")
return segments
def compose_with_anchors(
material_clips: list[str],
audio_path: str,
output_path: str,
silence_periods: list[dict],
total_duration: float,
subtitles_path: str | None = None,
resolution: str = DEFAULT_RESOLUTION,
) -> str:
"""
锚点驱动的画面切换合成
在每段静音处切换素材片段,添加交叉淡变过渡。
素材按顺序循环使用,避免单一素材贯穿全片。
参数:
material_clips: 素材视频列表
audio_path: 混合音频路径
silence_periods: [{"start": s, "end": e, "duration": d}, ...]
total_duration: 音频总时长(秒)
"""
num_clips = len(material_clips)
if num_clips == 0:
raise ValueError("素材列表为空")
if not silence_periods:
logger.info("无锚点,回落普通合成")
return compose_video(material_clips, audio_path, output_path,
subtitles_path=subtitles_path, resolution=resolution)
import tempfile
# 1. 在静音中点附近切割素材段落
transitions = [0.0] # 每个切换点的时间(秒)
for s in silence_periods:
mid = (s["start"] + s["end"]) / 2
if mid > transitions[-1] + 0.5: # 至少间隔0.5s
transitions.append(mid)
transitions.append(total_duration)
# 2. 为每段分配素材(循环使用)
seg_info = [] # [(clip_path, start, end), ...]
clip_idx = 0
for i in range(len(transitions) - 1):
seg_start = transitions[i]
seg_end = transitions[i + 1]
dur = seg_end - seg_start
if dur < 0.3:
continue # 跳过过短段落
seg_info.append((material_clips[clip_idx % num_clips], seg_start, seg_end))
clip_idx += 1
logger.info(f"锚点分段: {len(seg_info)} 段, 使用 {min(clip_idx, num_clips)}/{num_clips} 个素材")
# 3. 渲染每段为独立视频
temp_dir = tempfile.mkdtemp(prefix="ave_anchor_")
seg_files = []
for idx, (clip, seg_s, seg_e) in enumerate(seg_info):
out = os.path.join(temp_dir, f"seg_{idx:03d}.mp4")
dur = seg_e - seg_s
# 从该素材中截取一段 (用素材的一部分,避免只用开头)
clip_dur = _get_media_duration(clip)
offset_in_clip = (seg_s * 15) % max(clip_dur, 1) # 取素材中不同位置
offset_in_clip = min(offset_in_clip, max(clip_dur - dur, 0))
# 用 FFmpeg 截取该素材的一段
subprocess.run([
FFMPEG, "-y",
"-ss", str(offset_in_clip),
"-i", clip,
"-t", str(dur),
"-vf", f"scale={resolution.replace('x',':')},setsar=1,fps=30",
"-c:v", "libx264", "-preset", "fast", "-crf", "23",
out,
], capture_output=True, timeout=120, check=True)
seg_files.append(out)
# 4. 简单 concat 拼接 (含交叉淡变的 xfade 链 >10段容易 FFmpeg 崩溃)
# 用 concat demuxer 直接拼接,素材切换本身已提供视觉变化
concat_list = _create_concat_list(seg_files)
cmd = [
FFMPEG, "-y",
"-f", "concat", "-safe", "0", "-i", concat_list,
"-i", audio_path,
"-af", "afade=t=in:d=1,afade=t=out:st={}:d=1".format(max(0, total_duration - 1)),
]
if subtitles_path and os.path.exists(subtitles_path):
cmd += ["-vf", f"subtitles={subtitles_path}"]
cmd += ["-shortest", "-c:v", "libx264", "-preset", "medium", "-b:v", DEFAULT_BITRATE,
"-c:a", "aac", "-b:a", "192k", "-pix_fmt", "yuv420p", output_path]
subprocess.run(cmd, capture_output=True, check=True, timeout=600)
# 清理
import shutil
shutil.rmtree(temp_dir, ignore_errors=True)
logger.info(f"锚点驱动合成完成: {output_path}")
return output_path
def concat_segments(segment_files: list[str], output_path: str = "final.mp4") -> str:
"""拼接分段视频"""
concat_file = _create_concat_list(segment_files)
subprocess.run([
FFMPEG, "-y",
"-f", "concat", "-safe", "0", "-i", concat_file,
"-c", "copy",
output_path,
], capture_output=True, check=True)
logger.info(f"分段拼接完成: {output_path}")
return output_path
# ── 内部工具 ──────────────────────────────────────────────
def _create_concat_list(file_paths: list[str]) -> str:
"""创建 FFmpeg concat 文件列表"""
tmp = tempfile.NamedTemporaryFile(
mode="w", suffix=".txt", delete=False, encoding="utf-8"
)
for fp in file_paths:
abs_path = os.path.abspath(fp)
tmp.write(f"file '{abs_path}'\n")
tmp.close()
return tmp.name
def _get_media_duration(path: str) -> float:
"""获取媒体文件时长(秒) —— 用 lib.ffmpeg(本机 ffprobe 实为 ffmpeg 符号链接)"""
try:
from lib.ffmpeg import probe_duration
d = probe_duration(path)
if d > 0:
return d
except Exception:
pass
logger.warning(f"无法获取时长,默认 30s: {path}")
return 30.0
def _sec_to_ass_time(seconds: float) -> str:
"""秒 → ASS 时间格式 H:MM:SS.cc"""
h = int(seconds // 3600)
m = int((seconds % 3600) // 60)
s = int(seconds % 60)
cs = int((seconds - int(seconds)) * 100)
return f"{h}:{m:02d}:{s:02d}.{cs:02d}"
def _render_segment(
material_clips: list[str],
audio_path: str,
start_sec: float,
end_sec: float,
output_path: str,
subtitles_path: str | None = None,
resolution: str = DEFAULT_RESOLUTION,
):
"""渲染单个分段(裁剪音频+对齐素材)"""
duration = end_sec - start_sec
# 用 concat 合并所有素材作为视频源
concat_file = _create_concat_list(material_clips)
filters = [
f"[0:v]setpts=PTS-STARTPTS,scale={resolution.replace('x',':')},fps=30[v]",
f"[1:a]atrim=start={start_sec}:duration={duration},asetpts=PTS-STARTPTS[a]",
]
if subtitles_path and os.path.exists(subtitles_path):
filters.append(f"[v]subtitles={subtitles_path}[vo]")
map_v = "[vo]"
else:
map_v = "[v]"
cmd = [
FFMPEG, "-y",
"-f", "concat", "-safe", "0", "-i", concat_file,
"-i", audio_path,
"-filter_complex", ";".join(filters),
"-map", map_v, "-map", "[a]",
"-shortest",
"-c:v", "libx264", "-preset", "medium", "-b:v", DEFAULT_BITRATE,
"-c:a", "aac", "-b:a", "192k",
"-pix_fmt", "yuv420p",
output_path,
]
subprocess.run(cmd, capture_output=True, check=True, timeout=600)
@@ -0,0 +1,62 @@
"""
composer/frames.py — 视频抽帧工具(首尾帧衔接用)
用途:`chain_from_prev` 的镜头需要「上一镜的尾帧」作为生成参考,
这里提供 ffmpeg 抽帧(零成本、本地处理)。
"""
import subprocess
import sys
from pathlib import Path
_SCRIPT_ROOT = Path(__file__).resolve().parent.parent
if str(_SCRIPT_ROOT) not in sys.path:
sys.path.insert(0, str(_SCRIPT_ROOT))
from lib.logger import get_logger
log = get_logger("composer.frames")
def _ffmpeg() -> str:
try:
from lib.ffmpeg import FFMPEG
return FFMPEG
except Exception:
return "ffmpeg"
def extract_tail_frame(video_path: str, out_path: str, offset: float = 0.1) -> str:
"""抽取视频**最后一帧**(默认取结束前 0.1 秒处,避开黑帧/抖动)
用于首尾帧衔接:本镜的起手画面 = 上一镜的收尾画面 → 视觉连续。
"""
src = Path(video_path)
if not src.exists():
raise FileNotFoundError(f"视频不存在: {video_path}")
dst = Path(out_path)
dst.parent.mkdir(parents=True, exist_ok=True)
r = subprocess.run(
[_ffmpeg(), "-y", "-sseof", f"-{offset}", "-i", str(src),
"-frames:v", "1", "-q:v", "2", str(dst)],
capture_output=True, text=True, timeout=90)
if r.returncode != 0 or not dst.exists() or dst.stat().st_size < 500:
raise RuntimeError(f"抽尾帧失败: {r.stderr.strip()[-160:]}")
log.debug(" 抽尾帧: %s → %s", src.name, dst.name)
return str(dst)
def extract_head_frame(video_path: str, out_path: str, offset: float = 0.0) -> str:
"""抽取视频**第一帧**(用于把"上一镜的收尾"作为本镜的起手参考)"""
src = Path(video_path)
if not src.exists():
raise FileNotFoundError(f"视频不存在: {video_path}")
dst = Path(out_path)
dst.parent.mkdir(parents=True, exist_ok=True)
cmd = [_ffmpeg(), "-y"]
if offset:
cmd += ["-ss", str(offset)]
cmd += ["-i", str(src), "-frames:v", "1", "-q:v", "2", str(dst)]
r = subprocess.run(cmd, capture_output=True, text=True, timeout=90)
if r.returncode != 0 or not dst.exists() or dst.stat().st_size < 500:
raise RuntimeError(f"抽首帧失败: {r.stderr.strip()[-160:]}")
return str(dst)
@@ -0,0 +1,457 @@
"""
AVE hybrid — 口播+卡点融合管线
功能:
将口播的语音锚点驱动画面切换 + 卡点的BGM能量驱动变速结合起来。
人声决定"什么时候切画面", BGM决定"画面怎么动"。
流程:
1. 检测人声锚点 (静音段 → 段边界)
2. 检测 BGM 节拍 + 能量特征
3. 混音 (人声+BGM, BGM 音量避让)
4. 为每段分配素材 + 能量驱动变速
5. 渲染每段 + 字幕叠加
6. 帧锁定拼接
用法:
from composer.hybrid import compose_hybrid
compose_hybrid(
voice_path="narration.wav",
bgm_path="bgm.wav",
output_path="hybrid.mp4",
texts=["第一段文案", "第二段文案", ...],
)
"""
import sys, os; sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import json
import os
import shutil
import subprocess
import tempfile
from pathlib import Path
from typing import Optional
from lib.logger import get_logger
from lib.ffmpeg import FFMPEG # noqa: E402 (FFmpeg 绝对路径)
logger = get_logger("hybrid")
def compose_hybrid(
voice_path: str,
bgm_path: str,
output_path: str = "hybrid.mp4",
material_clips: list[str] | None = None,
resolution: str = "1080x1920",
texts: list[str] | None = None,
pexels_api_key: str = "",
pexels_search: str = "",
# 口播锚点参数
min_silence_sec: float = 0.15,
voice_volume: float = 1.0,
bgm_volume: float = 0.35,
# 卡点速度参数
enable_speed_ramp: bool = True,
base_speed: float = 1.0,
high_speed: float = 1.5,
low_speed: float = 0.7,
# 节拍分组
group_size: int = 4,
) -> str:
"""
口播+卡点融合管线
参数:
voice_path: 人声 WAV 路径
bgm_path: BGM 音频路径
output_path: 输出视频路径
material_clips: 素材视频/图片列表 (可选)
resolution: 输出分辨率
texts: 每段文字 (用于字幕, 可选)
pexels_api_key: Pexels API Key (素材不够时补充)
pexels_search: Pexels 搜索关键词
min_silence_sec: 最小静音时长 (秒), 用于分割段
voice_volume: 人声音量 (默认 1.0)
bgm_volume: BGM 音量 (默认 0.35)
enable_speed_ramp: 是否启用能量驱动变速
base_speed: 基础速度
high_speed: 高能段速度
low_speed: 低能段速度
group_size: BGM 节拍分组数
返回: output_path
"""
logger.info("=== 口播+卡点融合模式 ===")
logger.info(f"人声: {voice_path}")
logger.info(f"BGM: {bgm_path}")
# ── 1. 检测人声锚点 ──
from anchor_extractor.extractor import get_silence_periods
silences = get_silence_periods(voice_path, min_silence_sec=min_silence_sec)
# 获取人声总时长
voice_duration = _get_duration(voice_path)
# 从静音段反推活跃段 (说话段)
voice_segments = _silences_to_segments(silences, voice_duration)
logger.info(f" 锚点检测: {len(silences)} 个静音 → {len(voice_segments)} 个说话段")
# ── 2. 检测 BGM 节拍 (含能量) ──
from composer.beat_sync import detect_beats
beat_info = detect_beats(bgm_path, group_size=group_size, enable_energy=enable_speed_ramp)
bgm_segments = beat_info["segments"]
logger.info(f" BPM: {beat_info['bpm']:.0f}, {beat_info['beat_count']}拍, {len(bgm_segments)}组")
# ── 3. 混音 (人声+BGM, 避让) ──
temp_dir = tempfile.mkdtemp(prefix="ave_hybrid_")
mixed_audio = os.path.join(temp_dir, "mixed.wav")
# BGM 避让: 说话时压低BGM
mixed_audio = _duck_and_mix(
voice_path, bgm_path, voice_segments, mixed_audio,
voice_volume=voice_volume, bgm_volume=bgm_volume,
)
# ── 4. 准备素材 ──
clips = list(material_clips) if material_clips else []
if len(clips) < len(voice_segments) and pexels_api_key:
from material_producer.pexels.search import search_videos
needed = len(voice_segments) - len(clips)
logger.info(f" 素材不足, 从 Pexels 搜索 {needed} 个...")
kw = pexels_search or "background video"
found = search_videos(kw, count=needed, api_key=pexels_api_key, orientation="portrait")
for f in found:
clips.append(f["path"])
logger.info(f" 共 {len(clips)} 个素材")
if not clips:
raise ValueError("无可用素材")
# ── 5. 为每段分配素材 + 计算速度 ──
# 将 BGM 节拍能量映射到每个说话段上
fps = 30
seg_plans = [] # [(clip_path, adjusted_dur, speed), ...]
for idx, vseg in enumerate(voice_segments):
clip = clips[idx % len(clips)]
v_start = vseg["start"]
v_end = vseg["end"]
v_dur = v_end - v_start
if v_dur < 0.5:
continue
# 在该时间段内计算 BGM 平均能量
avg_energy = _average_energy_in_range(bgm_segments, v_start, v_end)
speed = base_speed
if enable_speed_ramp:
if avg_energy > 0.6:
speed = base_speed + (high_speed - base_speed) * min((avg_energy - 0.6) / 0.4, 1.0)
elif avg_energy < 0.3:
speed = low_speed + (base_speed - low_speed) * (avg_energy / 0.3)
speed = max(0.5, min(2.0, speed))
# 帧锁定时长
frames = round(v_dur * fps)
dur_aligned = frames / fps
seg_plans.append({
"clip": clip,
"start": v_start,
"end": v_end,
"duration": dur_aligned,
"speed": speed,
"energy": avg_energy,
})
logger.info(f" 分配 {len(seg_plans)} 段素材"
+ (f", 变速范围 {low_speed:.1f}~{high_speed:.1f}x" if enable_speed_ramp else ""))
# ── 6. 渲染每段 ──
seg_files = []
for idx, plan in enumerate(seg_plans):
clip = plan["clip"]
dur = plan["duration"]
speed = plan["speed"]
mt = dur / max(speed, 0.01) # 素材需要的时间长度
seg_file = os.path.join(temp_dir, f"seg_{idx:03d}.mp4")
# 从素材中截取一段 (考虑变速)
clip_dur = _get_duration(clip)
offset = (plan["start"] * 17) % max(clip_dur - mt, 0.1) if clip_dur > mt else 0
raw_seg = os.path.join(temp_dir, f"seg_{idx:03d}_raw.mp4")
subprocess.run([
FFMPEG, "-y",
"-ss", str(offset),
"-i", clip,
"-t", str(mt),
"-vf", f"scale={resolution.replace('x',':')},setsar=1,fps={fps}",
"-c:v", "libx264", "-preset", "fast", "-crf", "23",
"-an", raw_seg,
], capture_output=True, timeout=120, check=True)
if speed != 1.0 and 0.5 <= speed <= 2.0:
subprocess.run([
FFMPEG, "-y", "-i", raw_seg,
"-filter_complex", f"setpts={1/speed}*PTS[v]",
"-map", "[v]",
"-c:v", "libx264", "-preset", "fast", "-crf", "23",
"-pix_fmt", "yuv420p", seg_file,
], capture_output=True, timeout=120, check=True)
else:
os.replace(raw_seg, seg_file)
seg_files.append(seg_file)
if not seg_files:
raise RuntimeError("渲染失败: 无有效段")
# ── 7. 拼接 ──
if len(seg_files) == 1:
cmd = [FFMPEG, "-y", "-i", seg_files[0], "-i", mixed_audio,
"-shortest", "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", output_path]
subprocess.run(cmd, capture_output=True, timeout=120, check=True)
else:
_concat_hybrid_segments(seg_files, mixed_audio, output_path, seg_plans, fps)
# ── 8. 字幕叠加 ──
if texts and len(texts) > 0 and seg_plans:
_add_text_subtitles(output_path, texts, seg_plans, resolution)
# ── 9. 清理 ──
shutil.rmtree(temp_dir, ignore_errors=True)
final_size = os.path.getsize(output_path) / 1024 / 1024
logger.info(f"✅ 融合完成: {output_path} ({final_size:.1f}MB, "
f"{len(seg_files)} 段{', 能量变速' if enable_speed_ramp else ''})")
return output_path
# ── 内部辅助函数 ──────────────────────────────────────────
def _silences_to_segments(silences: list[dict], total_duration: float) -> list[dict]:
"""从静音段反推人声活跃段"""
if not silences:
return [{"start": 0.0, "end": total_duration}]
segments = []
prev_end = 0.0
for s in silences:
s_start = s.get("start", 0)
s_end = s.get("end", 0)
if s_start > prev_end + 0.1:
segments.append({"start": prev_end, "end": s_start})
prev_end = s_end
if prev_end < total_duration - 0.3:
segments.append({"start": prev_end, "end": total_duration})
if not segments:
return [{"start": 0.0, "end": total_duration}]
return segments
def _average_energy_in_range(bgm_segments: list[dict], start: float, end: float) -> float:
"""计算时间范围 [start, end] 内 BGM 的平均能量"""
energies = []
for seg in bgm_segments:
seg_start = seg["start"]
seg_end = seg["end"]
# 检查重叠
if seg_end < start or seg_start > end:
continue
energy = seg.get("energy", 0.5)
overlap = min(seg_end, end) - max(seg_start, start)
if overlap > 0:
energies.append((energy, overlap))
if not energies:
return 0.5
# 加权平均 (按重叠时长)
total_weight = sum(w for _, w in energies) or 1.0
avg = sum(e * w for e, w in energies) / total_weight
return max(0.0, min(1.0, avg))
def _duck_and_mix(
voice_path: str,
bgm_path: str,
voice_segments: list[dict],
output_path: str,
voice_volume: float = 1.0,
bgm_volume: float = 0.35,
) -> str:
"""
BGM 音量避让混音:
- 说话时: BGM 音量降到 bgm_volume * 0.4
- 间隙时: BGM 恢复到 bgm_volume
"""
import numpy as np
import soundfile as sf
# 加载音频
voice, sr_v = sf.read(voice_path)
bgm, sr_b = sf.read(bgm_path)
# 重采样 BGM 到相同 sr
if sr_b != sr_v:
import librosa
bgm = librosa.resample(bgm, orig_sr=sr_b, target_sr=sr_v)
# 补齐 BGM 到人声长度
if len(bgm) < len(voice):
repeats = int(np.ceil(len(voice) / len(bgm)))
bgm = np.tile(bgm, repeats)
bgm = bgm[:len(voice)]
# 创建音量包络: 说话段压低 BGM
bgm_envelope = np.ones(len(voice))
for seg in voice_segments:
s = int(seg["start"] * sr_v)
e = int(seg["end"] * sr_v)
bgm_envelope[s:e] = 0.4 # 说话时 BGM 降到 40%
# 平滑过渡 (0.05s fade)
fade_samples = int(0.05 * sr_v)
for i in range(1, len(bgm_envelope) - fade_samples * 2):
if bgm_envelope[i] != bgm_envelope[i - 1]:
# 在变化点做淡变
start = max(0, i - fade_samples)
end = min(len(bgm_envelope), i + fade_samples)
for j in range(start, end):
frac = (j - start) / max(end - start, 1)
bgm_envelope[j] = bgm_envelope[start] + frac * (bgm_envelope[end - 1] - bgm_envelope[start])
# 混音
voice_float = voice.astype(np.float32)
bgm_float = bgm.astype(np.float32)
# 归一化到 [-1, 1]
voice_float /= max(np.max(np.abs(voice_float)), 0.001)
bgm_float /= max(np.max(np.abs(bgm_float)), 0.001)
mixed = voice_float * voice_volume + bgm_float * bgm_volume * bgm_envelope
# 限制防止爆音
peak = np.max(np.abs(mixed))
if peak > 0.99:
mixed /= peak * 1.02
sf.write(output_path, mixed, sr_v)
logger.debug(f" 混音完成: {output_path} ({sr_v}Hz, {len(voice)/sr_v:.1f}s)")
return output_path
def _concat_hybrid_segments(
seg_files: list[str],
mixed_audio: str,
output_path: str,
seg_plans: list[dict],
fps: int = 30,
) -> str:
"""帧锁定拼接混合音频"""
if len(seg_files) == 1:
cmd = [FFMPEG, "-y", "-i", seg_files[0], "-i", mixed_audio,
"-shortest", "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", output_path]
subprocess.run(cmd, capture_output=True, timeout=120, check=True)
return output_path
# concat demuxer 拼接视频
concat_file = tempfile.NamedTemporaryFile(suffix=".txt", delete=False, mode="w").name
with open(concat_file, "w") as f:
for sf in seg_files:
f.write(f"file '{os.path.abspath(sf)}'\n")
video_only = output_path.replace(".mp4", "_novideo.mp4")
cmd = [FFMPEG, "-y", "-f", "concat", "-safe", "0", "-i", concat_file,
"-c:v", "libx264", "-preset", "fast", "-crf", "23", "-an", video_only]
subprocess.run(cmd, capture_output=True, timeout=300, check=True)
# 叠加混合音频
cmd2 = [FFMPEG, "-y", "-i", video_only, "-i", mixed_audio,
"-shortest", "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", output_path]
subprocess.run(cmd2, capture_output=True, timeout=120, check=True)
os.unlink(concat_file)
if os.path.exists(video_only):
os.unlink(video_only)
logger.info(f" ✅ 拼接完成: {len(seg_files)} 段")
return output_path
def _add_text_subtitles(
video_path: str,
texts: list[str],
seg_plans: list[dict],
resolution: str,
):
"""为每段叠加文字字幕 (ASS 格式)"""
width, height = (int(x) for x in resolution.split("x"))
font_size = max(48, int(height * 0.05))
margin_v = int(height * 0.3)
lines = [
"[Script Info]", "ScriptType: v4.00+",
f"PlayResX: {width}", f"PlayResY: {height}",
"ScaledBorderAndShadow: yes", "",
"[V4+ Styles]",
"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
f"Style: Default,Noto Sans SC,{font_size},&H00FFFFFF,&H0000FFFF,&H00000000,&H80000000,-1,0,0,0,100,100,0,0,1,3,1,2,100,100,{margin_v},1",
"", "[Events]",
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text",
]
for i, text in enumerate(texts):
if i >= len(seg_plans):
break
start = seg_plans[i]["start"]
dur = seg_plans[i]["duration"]
end = start + dur
def _fmt_ts(sec: float) -> str:
h = int(sec // 3600)
m = int((sec % 3600) // 60)
s = sec % 60
return f"{h}:{m:02d}:{s:05.2f}"
lines.append(
f"Dialogue: 0,{_fmt_ts(start)},{_fmt_ts(end)},Default,,0,0,0,,{text}"
)
ass_path = video_path.replace(".mp4", "_hybrid_text.ass")
with open(ass_path, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
tmp = video_path.replace(".mp4", "_tmp.mp4")
subprocess.run([
FFMPEG, "-y", "-i", video_path,
"-vf", f"subtitles={ass_path}",
"-c:v", "libx264", "-preset", "fast", "-crf", "23",
"-c:a", "copy", "-pix_fmt", "yuv420p", tmp,
], capture_output=True, timeout=120, check=True)
os.replace(tmp, video_path)
os.remove(ass_path)
logger.info(f" ✅ 字幕叠加: {len(texts)} 段")
def _get_duration(path: str) -> float:
"""获取媒体文件时长(秒)
用 lib.ffmpeg.probe_duration(ffmpeg -i 解析)——
本机 ffprobe 实为 ffmpeg 的符号链接,不支持 -show_entries(会静默失败)
"""
try:
from lib.ffmpeg import probe_duration
d = probe_duration(path)
return d if d > 0 else 30.0
except Exception:
return 30.0
@@ -0,0 +1,508 @@
#!/usr/bin/env python3
"""
AVE lipsync — Kling LipSync 集成
功能:
Audio→Video: 输入视频 + 语音音频 → 输出口型对齐的视频
Text→Video: 输入视频 + 文本 → 输出口型对齐的视频 (TTS自动)
API:
fal.ai endpoint (Kling LipSync 托管在 fal.ai)
成本: $0.014/5s ≈ ¥0.10/秒 ≈ ¥0.50/5秒镜头
用法:
python lipsync.py --video clip.mp4 --audio voice.wav --output synced.mp4
python lipsync.py --video clip.mp4 --text "你好世界" --output synced.mp4
集成:
作为口播策略后处理钩子, 在 video_factory.py 中调用
"""
import sys
import os
import json
import hashlib
import time
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from lib.config import load_config
from lib.cost_tracker import get_tracker
from lib.logger import get_logger
logger = get_logger("lipsync")
# ── 缓存 ──
CACHE_DIR = Path(os.environ.get("AVE_CACHE_DIR",
str(Path.home() / "workbuddy-agent-os/agent-local/tools/ave/cache/lipsync")))
CACHE_DIR.mkdir(parents=True, exist_ok=True)
# ── fal.ai 端点 ──
# Kling LipSync 通过 fal.ai 提供
FAL_BASE = "https://fal.run"
ENDPOINTS = {
"audio_to_video": f"{FAL_BASE}/fal-ai/kling-video/lipsync/audio-to-video",
"text_to_video": f"{FAL_BASE}/fal-ai/kling-video/lipsync/text-to-video",
}
# ═══════════════════════════════════════════════════════════
# 核心 API
# ═══════════════════════════════════════════════════════════
def lipsync_audio_to_video(
video_path: str,
audio_path: str,
output_path: str = "",
force: bool = False,
api_key: str = "",
) -> str:
"""
Audio→Video 唇形同步
参数:
video_path: 输入视频路径 (2-10s, 720p/1080p, ≤100MB)
audio_path: 输入音频路径 (.mp3/.wav/.m4a, ≤5MB)
output_path: 输出路径
force: 强制重新生成
api_key: fal.ai API Key
返回:
本地输出视频路径
"""
if not output_path:
joint = hashlib.md5(
(video_path + audio_path).encode()
).hexdigest()[:16]
output_path = str(CACHE_DIR / f"lipsync_{joint}.mp4")
# 缓存
if os.path.exists(output_path) and not force:
logger.info(f"缓存命中: {output_path}")
return output_path
cfg = load_config()
api_key = api_key or cfg.get("fal", {}).get("api_key", "")
if not api_key:
raise ValueError("缺少 fal.ai API Key (config fal.api_key)")
logger.info(f"LipSync Audio→Video:")
logger.info(f" video: {video_path}")
logger.info(f" audio: {audio_path}")
# 上传文件或传URL (fal.ai 支持直接传base64或文件URL)
# 方案: 使用 fal.ai 的 upload 端点
video_url = _upload_to_fal(video_path, api_key)
audio_url = _upload_to_fal(audio_path, api_key)
if not video_url or not audio_url:
raise RuntimeError("上传文件到 fal.ai 失败")
logger.info(f" video_url: {video_url[:60]}...")
logger.info(f" audio_url: {audio_url[:60]}...")
# 提交任务
result = _submit_and_poll(
endpoint=ENDPOINTS["audio_to_video"],
payload={
"video_url": video_url,
"audio_url": audio_url,
},
api_key=api_key,
timeout_sec=120,
)
# 下载结果
video_url_res = result.get("video", {}).get("url", "") or result.get("output", "")
if not video_url_res:
raise RuntimeError(f"fal.ai 返回无视频URL: {json.dumps(result, ensure_ascii=False)[:200]}")
logger.info(f" 下载结果中...")
_download(video_url_res, output_path)
# 费用
tracker = get_tracker()
try:
dur = _get_media_duration(output_path)
except Exception:
dur = 5 # 默认5秒
tracker.log("KlingLipSync", duration=dur, note=f"audio-to-video ¥0.014×{dur}s")
sz_mb = os.path.getsize(output_path) / 1024 / 1024
logger.info(f" ✅ LipSync完成: {output_path} ({sz_mb:.1f}MB, {dur:.1f}s)")
return output_path
def lipsync_text_to_video(
video_path: str,
text: str,
voice_id: str = "default",
output_path: str = "",
force: bool = False,
api_key: str = "",
) -> str:
"""
Text→Video 唇形同步 (自动 TTS + 口型对齐, ≤120字)
参数:
video_path: 输入视频路径
text: 口播文本 (≤120字)
voice_id: 音色ID
output_path: 输出路径
force: 强制重新生成
返回:
本地输出视频路径
"""
if len(text) > 120:
logger.warning(f"文本过长 ({len(text)}字), 截断至120字")
text = text[:120]
if not output_path:
joint = hashlib.md5(
(video_path + text).encode()
).hexdigest()[:16]
output_path = str(CACHE_DIR / f"lipsync_txt_{joint}.mp4")
if os.path.exists(output_path) and not force:
logger.info(f"缓存命中: {output_path}")
return output_path
cfg = load_config()
api_key = api_key or cfg.get("fal", {}).get("api_key", "")
if not api_key:
raise ValueError("缺少 fal.ai API Key")
logger.info(f"LipSync Text→Video: text={text[:40]}...")
video_url = _upload_to_fal(video_path, api_key)
if not video_url:
raise RuntimeError("上传视频到 fal.ai 失败")
result = _submit_and_poll(
endpoint=ENDPOINTS["text_to_video"],
payload={
"video_url": video_url,
"text": text,
"voice_id": voice_id,
},
api_key=api_key,
timeout_sec=180,
)
video_url_res = result.get("video", {}).get("url", "") or result.get("output", "")
if not video_url_res:
raise RuntimeError(f"fal.ai 返回无视频URL")
_download(video_url_res, output_path)
sz_mb = os.path.getsize(output_path) / 1024 / 1024
logger.info(f" ✅ LipSync Text→Video完成: {output_path} ({sz_mb:.1f}MB)")
return output_path
# ═══════════════════════════════════════════════════════════
# 内部工具
# ═══════════════════════════════════════════════════════════
def _upload_to_fal(file_path: str, api_key: str) -> str:
"""上传文件到 fal.ai, 返回可访问的 URL"""
import httpx
if not os.path.exists(file_path):
raise FileNotFoundError(f"文件不存在: {file_path}")
with open(file_path, "rb") as f:
resp = httpx.post(
f"{FAL_BASE}/fal-ai/upload",
headers={
"Authorization": f"Key {api_key}",
},
files={"file": f},
timeout=120,
)
resp.raise_for_status()
data = resp.json()
return data.get("url", "")
def _submit_and_poll(
endpoint: str,
payload: dict,
api_key: str,
timeout_sec: int = 120,
) -> dict:
"""提交 fal.ai 任务并轮询, 返回结果"""
import httpx
headers = {
"Authorization": f"Key {api_key}",
"Content-Type": "application/json",
}
# 提交
resp = httpx.post(
endpoint.replace("/run", ""), # 提交到非 /run 端点
headers=headers,
json=payload,
timeout=30,
)
# fal.ai 的 submit API
submit_url = endpoint
resp = httpx.post(submit_url, headers=headers, json=payload, timeout=30)
resp.raise_for_status()
data = resp.json()
# 检查是否直接返回结果(fal.ai 同步模式)
if data.get("status") == "completed" or data.get("video"):
return data
# 获取请求ID进行轮询
request_id = data.get("request_id", "")
if not request_id:
# 有的接口直接返回结果
if data.get("video", {}).get("url"):
return data
raise RuntimeError(f"fal.ai 无 request_id: {json.dumps(data, ensure_ascii=False)[:200]}")
status_url = f"{endpoint}/requests/{request_id}"
logger.info(f" 轮询: {request_id}")
start = time.time()
for i in range(60):
# 指数退避
if i < 5:
time.sleep(3)
elif i < 15:
time.sleep(5)
else:
time.sleep(10)
elapsed = time.time() - start
if elapsed > timeout_sec:
raise TimeoutError(f"fal.ai 超时 ({timeout_sec}s)")
try:
q = httpx.get(status_url, headers=headers, timeout=15)
q.raise_for_status()
status_data = q.json()
status = status_data.get("status", "")
if status == "COMPLETED":
return status_data
elif status in ("FAILED", "ERROR"):
raise RuntimeError(f"fal.ai 失败: {json.dumps(status_data, ensure_ascii=False)[:200]}")
logger.debug(f" [{i}] status={status}")
except httpx.HTTPError as e:
logger.debug(f" [{i}] 查询失败: {e}")
time.sleep(5)
raise TimeoutError("fal.ai 轮询超时")
def _download(url: str, path: str):
"""下载文件"""
import httpx
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
with httpx.stream("GET", url, timeout=600) as r:
r.raise_for_status()
with open(path, "wb") as f:
for chunk in r.iter_bytes(8192):
f.write(chunk)
def _get_media_duration(path: str) -> float:
"""获取媒体时长 (秒) —— 用 lib.ffmpeg(本机 ffprobe 实为 ffmpeg 符号链接)"""
from lib.ffmpeg import probe_duration
return probe_duration(path)
# ═══════════════════════════════════════════════════════════
# CLI
# ═══════════════════════════════════════════════════════════
def cli(args: list[str] | None = None):
import argparse
parser = argparse.ArgumentParser(description="Kling LipSync 唇形同步")
sub = parser.add_subparsers(dest="mode", required=True)
# audio → video
p_av = sub.add_parser("audio-to-video", help="音频驱动唇形同步")
p_av.add_argument("--video", required=True, help="输入视频")
p_av.add_argument("--audio", required=True, help="输入音频")
p_av.add_argument("--output", default="", help="输出路径")
p_av.add_argument("--force", action="store_true", help="强制重新生成")
# text → video
p_tv = sub.add_parser("text-to-video", help="文本驱动唇形同步")
p_tv.add_argument("--video", required=True, help="输入视频")
p_tv.add_argument("--text", required=True, help="口播文本 (≤120字)")
p_tv.add_argument("--voice-id", default="default", help="音色ID")
p_tv.add_argument("--output", default="", help="输出路径")
p_tv.add_argument("--force", action="store_true", help="强制重新生成")
parsed = parser.parse_args(args)
if parsed.mode == "audio-to-video":
out = lipsync_audio_to_video(
video_path=parsed.video,
audio_path=parsed.audio,
output_path=parsed.output,
force=parsed.force,
)
print(f"\n✅ LipSync 完成: {out}")
elif parsed.mode == "text-to-video":
out = lipsync_text_to_video(
video_path=parsed.video,
text=parsed.text,
voice_id=parsed.voice_id,
output_path=parsed.output,
force=parsed.force,
)
print(f"\n✅ LipSync 完成: {out}")
if __name__ == "__main__":
cli()
# ════════════════════════════════════════════════════════════
# ★ 百炼 VideoRetalk(推荐实现,2026-10-03 实测探明)
# 文档:https://help.aliyun.com/zh/model-studio/videoretalk-api
# 优势:用现有百炼 key(无需 fal.ai)· ¥0.08/秒 · 1800 秒免费额度
# 上传复用 AVE 已有的两步式(getPolicy → OSS 直传 → oss://)
# ════════════════════════════════════════════════════════════
DASHSCOPE_BASE = "https://dashscope.aliyuncs.com"
VR_SUBMIT_URL = f"{DASHSCOPE_BASE}/api/v1/services/aigc/image2video/video-synthesis"
VR_TASK_URL = f"{DASHSCOPE_BASE}/api/v1/tasks/{{tid}}"
VIDEORETALK_MODEL = "videoretalk"
VR_PRICE_PER_SEC = 0.08 # 元/秒(官方后付费单价)
VR_FREE_QUOTA_SEC = 1800 # 官方免费额度(秒)
def vr_estimate_cost(duration_sec: float) -> float:
"""对口型成本估算(元)—— 实际优先消耗 1800 秒免费额度"""
return round(max(0.0, float(duration_sec)) * VR_PRICE_PER_SEC, 2)
def _vr_upload(path: str, api_key: str) -> str:
"""两步式上传 → oss:// URL(与 person_swap 同一套;oss:// 需配 Resolve header)"""
import httpx
p = Path(path)
r = httpx.get(f"{DASHSCOPE_BASE}/api/v1/uploads?action=getPolicy&model={VIDEORETALK_MODEL}",
headers={"Authorization": f"Bearer {api_key}"}, timeout=30)
if r.status_code != 200:
raise RuntimeError(f"获取上传凭证失败 HTTP {r.status_code}: {r.text[:200]}")
d = r.json().get("data") or {}
key = f"{d.get('upload_dir', '')}/{p.name}"
form = {
"OSSAccessKeyId": d.get("oss_access_key_id", ""),
"policy": d.get("policy", ""),
"signature": d.get("signature", ""),
"key": key,
"x-oss-object-acl": d.get("x_oss_object_acl", "private"),
"x-oss-forbid-overwrite": "true",
}
with open(p, "rb") as f:
r2 = httpx.post(d["upload_host"], data=form,
files={"file": (p.name, f, "application/octet-stream")}, timeout=300,
trust_env=False)
if r2.status_code not in (200, 201, 204):
raise RuntimeError(f"OSS 直传失败 HTTP {r2.status_code}: {r2.text[:200]}")
return f"oss://{key}"
def lipsync_videoretalk(video_path: str, audio_path: str, output_path: str,
api_key: str = "", ref_image_path: str = "",
video_extension: bool = False,
timeout: int = 1800, progress_cb=None) -> str:
"""人物视频 + 人声 → **口型匹配**的新视频(百炼 VideoRetalk)
· 成本 ¥0.08/秒(有 1800 秒免费额度)
· 视频:正面近景、2~120s、mp4/mov、15~60fps、边长 640~2048
· 音频:干净人声、2~120s(TTS 产物天然满足)
· ref_image_path:角色参考图(可选,提升人脸匹配;我们正好有)
返回输出视频本地路径。
"""
import httpx
api_key = api_key or (load_config().get("aliyun", {}) or {}).get("api_key", "")
if not api_key:
raise ValueError("VideoRetalk 需要百炼 API Key")
_cb = progress_cb or (lambda p, n: None)
vdur = _get_media_duration(video_path)
if vdur and not (2.0 < vdur < 120.0):
raise ValueError(f"视频时长 {vdur:.1f}s 超出 VideoRetalk 要求(2~120 秒)")
_cb(5, "上传视频")
vurl = _vr_upload(video_path, api_key)
_cb(15, "上传音频")
aurl = _vr_upload(audio_path, api_key)
rurl = ""
if ref_image_path and Path(ref_image_path).exists():
try:
_cb(20, "上传人脸参考图")
rurl = _vr_upload(ref_image_path, api_key)
except Exception as e:
logger.warning("参考图上传失败(继续):%s", e)
inp = {"video_url": vurl, "audio_url": aurl}
if rurl:
inp["ref_image_url"] = rurl
payload = {
"model": VIDEORETALK_MODEL,
"input": inp,
"parameters": {"video_extension": bool(video_extension)},
}
hdr = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"X-DashScope-Async": "enable",
"X-DashScope-OssResourceResolve": "enable", # ★ 用 oss:// 时必须
}
_cb(25, "提交对口型任务")
r = httpx.post(VR_SUBMIT_URL, headers=hdr, json=payload, timeout=60, trust_env=False)
if r.status_code != 200:
raise RuntimeError(f"提交失败 HTTP {r.status_code}: {r.text[:300]}")
tid = (r.json().get("output") or {}).get("task_id", "")
if not tid:
raise RuntimeError(f"提交返回无 task_id: {r.text[:300]}")
logger.info(" VideoRetalk 任务已提交: %s(预估 ¥%.2f)", tid, vr_estimate_cost(vdur))
# 轮询
deadline = time.time() + timeout
last = ""
while time.time() < deadline:
rr = httpx.get(VR_TASK_URL.format(tid=tid),
headers={"Authorization": f"Bearer {api_key}"}, timeout=30, trust_env=False)
out = (rr.json() or {}).get("output") or {}
st = out.get("task_status", "")
if st == "SUCCEEDED":
url = out.get("video_url") or ""
if not url:
results = out.get("results") or []
url = (results[0].get("video_url") or results[0].get("url")) if results else ""
if not url:
raise RuntimeError(f"成功但无视频 URL: {str(out)[:200]}")
_cb(95, "下载结果")
_download(url, output_path)
_cb(100, "完成")
logger.info(" ✅ 对口型完成: %s", Path(output_path).name)
return output_path
if st in ("FAILED", "CANCELED", "UNKNOWN"):
raise RuntimeError(f"任务 {st}: {(out.get('message') or out.get('code') or '')[:200]}")
if st != last:
last = st
logger.info(" [%s] 对口型任务状态: %s", tid[:8], st)
_cb(30, f"处理中({st})")
time.sleep(10)
raise RuntimeError(f"对口型超时(>{timeout}s)")
# ── CLI 扩展:--provider videoretalk ──
def cli_videoretalk(video: str, audio: str, output: str,
ref_image: str = "", video_extension: bool = False):
def _p(p, n):
print(f" {p}% {n}")
out = lipsync_videoretalk(video, audio, output, ref_image_path=ref_image,
video_extension=video_extension, progress_cb=_p)
print(f"\n✅ 输出: {out}")
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,111 @@
"""
composer/qa.py — 出片质检辅助(接触印相表)
用途:出片后自动抽若干关键帧拼成一张「接触印相表」(contact sheet),
让人**一眼**核对:
· 口型是否与台词对上(有台词的镜头)
· 跨镜人物是否同一张脸(一致性)
· 表情/构图/光线是否连贯
纯本地 ffmpeg 处理,零成本。
"""
import subprocess
import sys
from pathlib import Path
_SCRIPT_ROOT = Path(__file__).resolve().parent.parent
if str(_SCRIPT_ROOT) not in sys.path:
sys.path.insert(0, str(_SCRIPT_ROOT))
from lib.logger import get_logger
log = get_logger("composer.qa")
def _ffmpeg() -> str:
try:
from lib.ffmpeg import FFMPEG
return FFMPEG
except Exception:
return "ffmpeg"
def _duration(video_path: str) -> float:
try:
from lib.ffmpeg import probe_duration
return float(probe_duration(video_path) or 0)
except Exception:
return 0.0
def make_contact_sheet(video_path: str, out_path: str,
cols: int = 4, rows: int = 2, tile_w: int = 360) -> str:
"""抽 `cols*rows` 帧均匀分布拼成一张图(默认 4x2 = 8 帧)
· 均匀采样(按视频时长算间隔),覆盖整个片段
· 输出 jpg,宽 ≈ cols * tile_w
"""
src = Path(video_path)
if not src.exists():
raise FileNotFoundError(f"视频不存在: {video_path}")
n = max(1, cols * rows)
dur = _duration(str(src))
interval = max(0.2, (dur / n)) if dur > 0 else 1.0
dst = Path(out_path)
dst.parent.mkdir(parents=True, exist_ok=True)
vf = f"fps=1/{interval:.3f},scale={tile_w}:-2,tile={cols}x{rows}"
r = subprocess.run(
[_ffmpeg(), "-y", "-i", str(src), "-vf", vf, "-frames:v", "1", "-q:v", "3", str(dst)],
capture_output=True, text=True, timeout=180)
if r.returncode != 0 or not dst.exists() or dst.stat().st_size < 1000:
raise RuntimeError(f"生成接触印相表失败: {r.stderr.strip()[-200:]}")
log.info(" 🖼 质检图: %s(%d 帧 · %dx%d)", dst.name, n, cols, rows)
return str(dst)
def make_shot_qa(job_dir: str, sb=None) -> dict:
"""为一次出片生成质检图(成片整体 + 每个镜头)
返回 {"final": path, "shots": [paths...]};失败不抛(质检是辅助,不应阻塞交付)
"""
out: dict = {"final": "", "shots": []}
d = Path(job_dir)
if not d.exists():
return out
# ① 成片整体
final = d / "final_muxed.mp4"
if not final.exists():
final = d / "final.mp4"
if final.exists():
try:
out["final"] = make_contact_sheet(str(final), str(d / "qa" / "final_sheet.jpg"))
except Exception as e:
log.warning(" ⚠️ 成片质检图失败: %s", e)
# ② 逐镜头(对口型后的片段优先,核对口型最准)
qa_dir = d / "qa"
shots = []
try:
cands = sorted(d.glob("lipsync_*.mp4")) or sorted(d.glob("shot_*.mp4"))
for i, v in enumerate(cands, 1):
try:
shots.append(make_contact_sheet(
str(v), str(qa_dir / f"{v.stem}_sheet.jpg"), cols=3, rows=2, tile_w=280))
except Exception as e:
log.debug(" 镜头质检图失败 %s: %s", v.name, e)
except Exception as e:
log.warning(" ⚠️ 逐镜质检图失败: %s", e)
out["shots"] = shots
return out
if __name__ == "__main__":
import argparse
ap = argparse.ArgumentParser(description="出片质检辅助(接触印相表)")
ap.add_argument("job_dir", help="出片目录(含 final.mp4 或 final_muxed.mp4)")
args = ap.parse_args()
r = make_shot_qa(args.job_dir)
print(f"成片质检图: {r['final'] or '(无)'}")
print(f"镜头质检图: {len(r['shots'])} 张")
for p in r["shots"][:8]:
print(f" {p}")
@@ -0,0 +1,801 @@
"""
composer/realism.py — 写实感增强(去 AI 感)
来源:外部方法论学习(详见 PLANS/REALISM_GUIDE.md + 09_ave/docs/realism/)
作用:为提示词编译提供三件东西——
① EMOTION_ACTIONS 情绪外化表(抽象情绪 → 具体身体动作)
② REALISM_ANCHORS 写实锚点包(按拍摄风格给"反 AI 感"的具体锚点)
③ check_conflicts 矛盾约束自检(写了互相抵消的组合)
"""
import re
# ════════════════════════════════════════════════════════════
# ① 情绪外化表
# 原则:视频模型看不懂抽象形容词,必须外化为身体细节;
# 每个镜头只写 1-2 个关键动作(多了稀释焦点)
# ════════════════════════════════════════════════════════════
EMOTION_ACTIONS: dict = {
"悲伤": "低头、肩膀微微颤抖、眼眶泛红,手指无意识地攥紧衣角",
"难过": "低头、肩膀微微颤抖、眼眶泛红,手指无意识地攥紧衣角",
"喜悦": "嘴角抑制不住地上扬、眉眼舒展,说话时眼睛弯起来",
"开心": "嘴角抑制不住地上扬、眉眼舒展,说话时眼睛弯起来",
"高兴": "嘴角抑制不住地上扬、眉眼舒展,说话时眼睛弯起来",
"紧张": "手指无意识地攥紧衣角、眼神闪躲、呼吸略急",
"焦虑": "频繁看表、手指轻敲、眼神游移、下意识抿嘴",
"愤怒": "双拳紧握、下颌线绷紧、胸口起伏,从牙缝里挤话",
"生气": "双拳紧握、下颌线绷紧、胸口起伏,从牙缝里挤话",
"释然": "长舒一口气、紧绷的肩膀放松下来,浮起淡淡的微笑",
"放松": "长舒一口气、紧绷的肩膀放松下来,浮起淡淡的微笑",
"恐惧": "瞳孔放大、后退半步、手不自觉抓住身边物体、嘴唇发抖",
"害怕": "瞳孔放大、后退半步、手不自觉抓住身边物体、嘴唇发抖",
"疲惫": "揉太阳穴、眼皮下沉、长呼气、动作迟缓",
"疲惫不堪": "揉太阳穴、眼皮下沉、长呼气、动作迟缓",
"专注": "眉头微皱、眼神锁定一点、嘴唇抿紧、身体前倾",
"认真": "眉头微皱、眼神锁定一点、嘴唇抿紧、身体前倾",
"犹豫": "张嘴又闭上、视线游移、手悬在半空、欲言又止",
"纠结": "张嘴又闭上、视线游移、手悬在半空、欲言又止",
"惊喜": "眼睛瞬间睁大、嘴巴微张、身体向前倾,笑容缓慢绽放",
"期待": "身体微微前倾、目光停在一点、呼吸放轻",
"尴尬": "视线移向别处、嘴角抽动一下、手指摸了摸鼻尖",
"无奈": "轻轻摇头、嘴角扯出一个笑、肩膀垮下来",
"委屈": "嘴唇抿成一条线、眼眶发热、目光低垂看向地面",
"好奇": "微微歪头、眼睛亮起来、视线跟着对象移动",
"警觉": "下颌收紧、眼神快速扫向声源、身体下意识绷住",
"疏离": "目光越过对方看向远处、表情平静没有起伏、动作缓慢",
"冷漠": "目光越过对方看向远处、表情平静没有起伏、动作缓慢",
}
def externalize_emotion(sentiment: str) -> str:
"""抽象情绪 → 具体身体动作;未命中则原样返回(调用方可自行处理)"""
s = (sentiment or "").strip()
if not s:
return ""
# 直接命中
if s in EMOTION_ACTIONS:
return EMOTION_ACTIONS[s]
# 含"紧张/好奇"这类复合写法(用 / 、 分隔)→ 取第一个能命中的
for sep in ("/", "、", ",", ",", " "):
if sep in s:
for piece in s.split(sep):
piece = piece.strip()
if piece in EMOTION_ACTIONS:
return EMOTION_ACTIONS[piece]
# 部分包含
for k, v in EMOTION_ACTIONS.items():
if k in s:
return v
return s
# ════════════════════════════════════════════════════════════
# ② 写实锚点包(按拍摄风格)
# 核心:用"设备型号 / 真实光源 / 微瑕疵 / 皮肤细节"替换抽象形容词
# ════════════════════════════════════════════════════════════
REALISM_ANCHORS: dict = {
# 📱 自拍 Vlog(用户核心诉求)
"selfie_pov": {
"name": "手机自拍 · 第一人称",
"device": "iPhone 前置摄像头拍摄,竖屏 9:16,一臂距离(鼻梁与脸颊有轻微广角透视变形)",
"hand": "手持自拍,画面随呼吸与手臂疲劳轻微起伏;偶尔抬手微调构图",
"light": "窗光从侧面打在脸上、室内顶灯偏暖;背景比人脸暗一档",
"skin": "真实皮肤纹理、毛孔与细小绒毛可见、不过度磨皮、没有塑料质感",
"flaws": ("前置镜头为**定焦**(不会有对焦搜索);近距离透视变形;"
"动态范围有限——窗外或灯下的高光容易过曝、暗部有可见噪点;"
"头发有几缕没梳顺"),
"motion": "说话时自然眨眼、吞咽、鼻翼微微起伏,视线偶尔离开镜头再回来",
"sound": "房间轻微混响、远处环境声、衣服摩擦声(手机麦克风拾音,人声比正常对话稍远)",
"_no": ("不要三脚架式的绝对稳定(自然的手持呼吸感要保留)、不要影棚布光、"
"不要滤镜磨皮、不要对焦搜索(手机前置是定焦)"),
},
# 伪纪录片 · 真实自然(现有默认)
"documentary_realism": {
"device": "手持摄影机质感,轻微呼吸起伏",
"hand": "镜头随拍摄者呼吸轻微移动,构图不是完美的",
"light": "现场实际光源(窗光、路灯、顶灯),明暗过渡自然",
"skin": "真实皮肤纹理、毛孔可见、不过度磨皮",
"flaws": "轻微噪点、暗部细节不完全干净、对焦有呼吸感",
"motion": "被拍摄者的动作不追求完美,有停顿与迟疑",
"_no": "不要运镜特效痕迹、不要 AI 塑料质感、不要字幕水印",
},
# 电影感
"cinematic": {
"device": "35mm 电影镜头,浅景深",
"hand": "三脚架或稳定器,运动平滑",
"light": "动机光(画面内有光源依据)+ 逆光轮廓",
"skin": "电影级肤色还原、保留皮肤质感",
"flaws": "轻微的镜头光晕、真实的光斑",
"motion": "调度从容,动作有起势与收势",
"_no": "不要手持晃动、不要纪实粗糙感",
},
# 棚拍干净
"studio_clean": {
"device": "专业摄影机,稳定机位",
"hand": "固定机位 / 滑轨",
"light": "柔光箱主光 + 轮廓光",
"skin": "干净但保留皮肤纹理(不要过度磨皮)",
"flaws": "极少的噪点,干净背景",
"motion": "表演可预期、动作清晰",
"_no": "不要手持晃动、不要环境杂光",
},
# 手持 Vlog · 第一视角(边走边拍,镜头=拍摄者的眼睛)
"vlog_handheld": {
"device": "手机或轻便相机手持拍摄,竖屏 9:16",
"hand": "第一视角手持,画面随走路上下起伏,转向时略有回摆",
"light": "自然光为主( daylight / 室内灯),明暗随走动变化",
"skin": "真实皮肤纹理、毛孔可见、不过度磨皮",
"flaws": "走动时的轻微抖动、暗部噪点、偶尔的手部入画",
"motion": "被拍对象自然走动、走动时说话有些喘",
"sound": "环境音随移动变化、脚步声、风噪",
"_no": "不要稳定器般平滑、不要影棚布光",
},
# 访谈纪实 · 半身对镜
"interview_doc": {
"device": "中焦镜头,半身构图,略浅景深",
"hand": "机位基本固定,允许极轻微的手持呼吸感",
"light": "柔和侧光或窗户光,面部立体但不过曝",
"skin": "真实皮肤纹理、皱纹与毛孔保留、不过度磨皮",
"flaws": "背景略暗、轻微噪点、被访者偶尔的停顿与眼神移动",
"motion": "被访者自然对镜说话,有停顿、思考、手势",
"sound": "安静室内混响、衣料摩擦声",
"_no": "不要花哨运镜、不要强风格化调色",
},
}
def get_anchors(shooting_style: str) -> dict:
"""按拍摄风格取锚点包;未知名回退 documentary_realism"""
s = (shooting_style or "").strip().lower()
if s in REALISM_ANCHORS:
return REALISM_ANCHORS[s]
# 常见别名
alias = {
"selfie": "selfie_pov", "vlog": "selfie_pov", "自拍": "selfie_pov",
"手机自拍": "selfie_pov", "第一人称": "selfie_pov",
"documentary": "documentary_realism", "纪实": "documentary_realism",
"film": "cinematic", "电影感": "cinematic", "movie": "cinematic",
"studio": "studio_clean", "棚拍": "studio_clean",
}
for k, v in alias.items():
if k in s:
return REALISM_ANCHORS[v]
return REALISM_ANCHORS["documentary_realism"]
# ════════════════════════════════════════════════════════════
# ③ 矛盾约束自检(写了等于没写)
# ════════════════════════════════════════════════════════════
CONFLICT_PAIRS: list = [
(("画面稳定", "稳定机位", "三脚架"), ("手持", "晃动", "呼吸起伏"),
"「画面稳定」与「手持晃动」冲突(稳定=三脚架语言)"),
(("电影感", "cinematic"), ("纪实", "documentary", "手持"),
"「电影感」与「纪实手持」冲突(风格相互拉扯)"),
(("胶片", "film grain", "颗粒感"), ("4K", "超清", "ultra sharp", "高清锐利"),
"「胶片感」与「超清」冲突(胶片感本身是模糊+颗粒)"),
(("慢动作", "slow motion"), ("高速", "快切", "high speed"),
"「慢动作」与「高速动作」冲突"),
]
def check_conflicts(text: str) -> list:
"""扫描提示词里的矛盾组合,返回告警列表(空 = 无冲突)
⚠️ 先剔除负向语境:负向约束里写「不要画面稳定」是在**否定**该项,
不能被当成"要求画面稳定"(否则与"手持晃动"误报冲突)。
"""
t = (text or "")
# 剔除"不要X / 避免X / 禁止X / 无X"片段(X 最多 12 字),再做正向检测
t_positive = re.sub(r"(不要|避免|禁止|无)[^、;。\n]{0,12}", " ", t)
warns = []
for group_a, group_b, msg in CONFLICT_PAIRS:
hit_a = next((a for a in group_a if a.lower() in t_positive.lower()), "")
hit_b = next((b for b in group_b if b.lower() in t_positive.lower()), "")
if hit_a and hit_b:
warns.append(f"{msg}(同时出现「{hit_a}」与「{hit_b}」)")
return warns
# ════════════════════════════════════════════════════════════
# 必加约束(护栏)
# ════════════════════════════════════════════════════════════
BASE_CONSTRAINTS = "不要水印、不要 Logo、不要字幕"
FACE_CONSTRAINTS = "人物面部稳定不变形、动作连贯自然不僵硬、无穿模无卡顿无闪烁"
ANTI_TWIN = "同一画面中不出现外形着装完全一致的人物(防止分身/双胞胎)"
def compose_constraints(extra_no: str = "", with_anti_twin: bool = True) -> str:
"""组装约束串(负向 + 正向要求,自动去重)
注意:两类语义不同——
· 负向约束("不要 X")用「负向约束:」引导
· 正向要求("人物面部稳定不变形"这类陈述句)用「画面要求:」引导
⚠️ 不能给正向要求硬加"不要"前缀(会把要求变成禁止,语义反转)
"""
negatives: list = [] # 形如 "不要水印"
positives: list = [] # 形如 "人物面部稳定不变形"
for x in BASE_CONSTRAINTS.split("、"):
x = x.strip()
if x:
negatives.append(x if x.startswith("不要") else "不要" + x)
for x in FACE_CONSTRAINTS.split("、"):
x = x.strip()
if x:
positives.append(x)
if with_anti_twin:
positives.append(ANTI_TWIN)
if extra_no:
e = extra_no.strip()
for x in e.split("、"):
x = x.strip()
# ⚠️ 每项单独去前缀(锚点包每项通常自带"不要",若只去整体前缀会出现"不要不要X")
while x.startswith("不要"):
x = x[len("不要"):].strip()
if x:
negatives.append("不要" + x)
def _dedup(seq):
"""**完全去重**(相同项只留一条)
⚠️ 我曾经想在这里做"子串合并"("不要字幕水印" 吞掉 "不要字幕"),
但实测证明那是**错的**:
· `不要字幕` 与 `不要字幕水印` 是两条**不同**的约束(后者更具体),都该保留
· 而且按子串匹配会把 `不要水印` 误判成 `字幕水印` 的子集 → 错误替换
结论:**只做完全去重,不做子串合并。**
"""
seen, out = set(), []
for it in seq:
k = it.strip()
if not k or k in seen:
continue
seen.add(k)
out.append(it)
return out
parts = []
if negatives:
parts.append("负向约束:" + "、".join(_dedup(negatives)))
if positives:
parts.append("画面要求:" + "、".join(_dedup(positives)))
return ";".join(parts)
# ════════════════════════════════════════════════════════════
# ④ 风格包(Style Pack)—— 把"导演提示词知识"变成可选参数
# 用户 2026-10-03 要求:把学到的提示词技巧整理成参数选项,
# 选一个风格 = 自动带上一整套「设备/光/机位/瑕疵/情绪/负面词」设定。
# ════════════════════════════════════════════════════════════
STYLE_PACKS: dict = {
"selfie_pov": {
"label": "📱 手机自拍 · 第一人称",
"device": "iPhone 前置摄像头,手持自拍,手臂伸出一臂距离",
"lighting": "室内自然光或窗光为主,光源不均匀,脸上有明显明暗过渡",
"camera": "轻微手持晃动,构图略微歪斜不居中,对焦偶尔漂移",
"flaws": "轻微手机噪点、真实肤质(可见毛孔与细纹)、有一缕碎发没整理好",
"emotion": "外化", "realism_default": "mild",
"negatives": ["影棚均匀布光", "三脚架固定机位", "过度磨皮", "商业修图感"],
},
"vlog_handheld": {
"label": "🚶 手持 Vlog · 第一视角",
"device": "手持相机或手机,走动跟拍,第一视角",
"lighting": "户外环境光/店内混合光源,色温不统一",
"camera": "明显手持晃动(模拟步行节奏),偶有快速转向,构图随意",
"flaws": "轻微运动模糊、曝光偶尔跳动、背景杂乱真实",
"emotion": "外化", "realism_default": "mild",
"negatives": ["稳定器丝滑运镜", "影棚打光", "摆拍感"],
},
"documentary_realism": {
"label": "🎥 伪纪录片 · 真实自然",
"device": "手持摄像机或长焦,像随手记录",
"lighting": "以现场自然光为主,不补光,允许暗部欠曝",
"camera": "长镜头、轻微呼吸感晃动、不稳定构图",
"flaws": "轻微胶片颗粒、真实环境杂乱、画面有生活痕迹",
"emotion": "外化", "realism_default": "mild",
"negatives": ["影棚布光", "摆拍摆姿", "商业感"],
},
"interview_doc": {
"label": "📺 访谈纪实 · 半身对镜",
"device": "固定机位,中焦镜头,半身构图",
"lighting": "柔和主光 + 环境光,背景稍暗",
"camera": "机位固定,极轻微晃动,人物可略偏离画面中心",
"flaws": "背景真实但不抢眼、肤色自然不修饰",
"emotion": "外化", "realism_default": "off",
"negatives": ["夸张运镜", "过度磨皮"],
},
"cinematic": {
"label": "🎞️ 电影感 · 考究构图",
"device": "专业电影机,变焦或定焦,景深控制",
"lighting": "有设计感的光比,侧逆光/轮廓光,暗部有层次",
"camera": "稳定运镜或精心设计的推拉摇移,构图讲究",
"flaws": "轻微胶片颗粒(不要数码锐化的塑料感)",
"emotion": "外化", "realism_default": "off",
"negatives": ["手机感", "随手拍", "画面杂乱"],
},
"studio_clean": {
"label": "💡 商业棚拍 · 高清干净",
"device": "专业摄影机,三脚架固定,中长焦",
"lighting": "专业布光:主光 + 辅光 + 轮廓光,光比均匀",
"camera": "稳定固定机位,构图工整居中",
"flaws": "(此风格刻意干净 —— 但保留真实肤质,避免塑料感)",
"emotion": "外化", "realism_default": "off",
"negatives": ["画面杂乱", "手持晃动", "环境噪音"],
},
"anime_2d": {
"label": "🎨 2D 动画 · 漫剧",
"device": "动画分镜,2D 手绘或动漫渲染",
"lighting": "动画式平光或风格化光影(非物理真实)",
"camera": "动画运镜:推拉摇移、定格、分格",
"flaws": "(动画无需真实瑕疵;改为保持线条稳定、色块不跳变)",
"emotion": "夸张外化(动漫式表情与动作幅度)", "realism_default": "off",
"negatives": ["写实人脸照片感", "真实摄影质感", "胶片颗粒", "真人实拍"],
},
"anime_3d": {
"label": "🧸 3D 动画 · 卡通渲染",
"device": "3D 渲染,卡通/风格化着色",
"lighting": "风格化打光,柔和阴影或卡通描边",
"camera": "3D 运镜(环绕/位移/景深)",
"flaws": "(保持模型一致,避免穿模与闪烁)",
"emotion": "夸张外化", "realism_default": "off",
"negatives": ["写实照片", "真人实拍", "皮肤毛孔", "胶片噪点"],
},
"product_ad": {
"label": "🧊 产品广告 · 精致特写",
"device": "微距或长焦,产品特写,浅景深",
"lighting": "精细打光:轮廓光 + 反光板,突出材质",
"camera": "稳定运镜(滑轨/环绕),构图讲究",
"flaws": "材质细节清晰(不是缩小版瑕疵)",
"emotion": "(产品无情绪;如有模特出镜则外化)", "realism_default": "off",
"negatives": ["画面杂乱", "廉价感", "低清晰度"],
},
}
# ════════════════════════════════════════════════════════════
# ⑤ 反真实词库(★ 研究核心发现)
# "AI 感"的头号来源:提示词里堆砌「4K/8K/超清/无瑕/大师作品」——
# 模型会理解成"要无瑕",于是过度磨皮、抹平纹理、统一光影 → 塑料感。
# 真实感增强开启时,自动剔除这些词。
# ════════════════════════════════════════════════════════════
ANTI_REALISM_WORDS: list = [
"4k", "8k", "4K", "8K", "超清", "超高清", "高清", "极致画质", "顶级画质",
"电影级", "电影感构图", "大片感", "杂志级",
"无瑕", "无瑕疵", "完美", "大师作品", "大师级", "杰作",
"masterpiece", "best quality", "ultra detailed", "ultra realistic",
"hyperrealistic", "photorealistic", "8k uhd", "highly detailed",
"过度锐化", "塑料感", "CG感", "3D渲染感",
]
# 反真实词在"写实类"风格下才剔除(动画类风格里这些词无害甚至有用)
_REALISM_STYLES = {"selfie_pov", "vlog_handheld", "documentary_realism",
"interview_doc", "cinematic", "studio_clean"}
def strip_anti_realism(text: str) -> tuple:
"""剔除会导致「塑料感」的反真实词(写实风格下)
返回 (清洗后文本, 被剔除的词列表)
"""
if not text:
return "", []
out = text
removed = []
for w in ANTI_REALISM_WORDS:
if w in out:
out = out.replace(w, "")
removed.append(w)
# 清理残留下的多余标点/空格
out = re.sub(r"[、,,]\s*(?=[、,,])", "", out)
out = re.sub(r"\s{2,}", " ", out).strip(" 、,,")
return out, removed
def apply_style_pack(prompt: str, shooting_style: str,
realism_boost: str = "mild") -> tuple:
"""把「风格包」应用到提示词上(去 AI 感的核心动作)
realism_boost: off(不动)/ mild(加瑕疵锚点)/ strong(瑕疵 + 剔除反真实词)
返回 (增强后的提示词, 说明列表)
"""
notes: list = []
if not prompt:
return prompt, notes
pack = STYLE_PACKS.get(shooting_style) or {}
boost = (realism_boost or "off").lower()
out = prompt
if boost == "off" or not pack:
return out, notes
# ① strong:剔除反真实词(研究里最大的坑)
if boost == "strong" and shooting_style in _REALISM_STYLES:
out, removed = strip_anti_realism(out)
if removed:
notes.append(f"已剔除反真实词:{'、'.join(removed[:6])}")
# ② 追加瑕疵锚点(如果提示词里还没有类似描述)
flaws = pack.get("flaws") or ""
if flaws and not any(k in out for k in ("噪点", "颗粒", "毛孔", "瑕疵", "纹理")):
out = out.rstrip("。. ") + f"。{flaws}。"
notes.append("已追加真实质感锚点")
# ③ strong:额外补一句"反完美"总纲
if boost == "strong":
tail = "真实拍摄的不完美感:光线不均匀、构图不刻意、画面有生活痕迹"
if "不完美" not in out and "不均匀" not in out:
out = out.rstrip("。. ") + f"。{tail}。"
notes.append("已追加反完美总纲")
return out, notes
def style_pack_options() -> dict:
"""供前端生成「拍摄风格」下拉(含中文标签)"""
return {k: v.get("label", k) for k, v in STYLE_PACKS.items()}
# ════════════════════════════════════════════════════════════
# ★ 去 AI 感扩展库(2026-10-04 研究落地)
# 来源:zhouwei713/seedance-prompt 的 anti-ai-checklist.md
# (原文存 09_ave/docs/realism/anti-ai-checklist.md)
#
# 核心洞察:**"AI 感"主要来自模型的默认审美**(电影化运镜/稳定画面/
# 商业调色/完美构图/磨皮皮肤)—— 必须用负面约束显式关闭,
# 并主动注入「非完美事件」才像真实拍摄。
# ════════════════════════════════════════════════════════════
# ① AI 默认行为 → 负面约束句(10 项,含"造成的问题")
AI_DEFAULT_NEGATIVES = [
("电影化平滑运镜(推轨/摇臂感)", "没有电影化的摄像机移动"),
("画面稳定如三脚架", "不要三脚架式的绝对稳定(自然手持呼吸感是允许的)"),
("现代商业调色(青橙色调、高对比)", "没有现代色彩分级"),
("完美居中构图", "不完美的构图,构图偏离中心"),
("场景自动加商店/咖啡馆/人群/广告牌", "没有商店、广告、咖啡馆、人群或商业活动"),
("自动配背景音乐", "没有音乐。没有音效设计。没有旁白"),
("表演感强、直视镜头摆姿势", "真实的即兴行为,无剧本的日常生活片段感"),
("皮肤过度平滑/磨皮感", "逼真的皮肤纹理"),
("角色跨镜头漂移", "在整个视频中保持一致的身份、服装、发型和外貌"),
("灯光过于均匀完美", "自然光,在阳光和阴影间移动时的曝光波动"),
]
# ② 非完美事件库(21 个 · 四类)—— 每镜头选一个,全片不重复
IMPERFECT_EVENTS = {
"photographer": [
"自动对焦在主体和前景/背景之间反复搜索",
"构图偏离中心(操作者试图跟上主体)",
"摄像机稍晚才捕捉到关键瞬间(人已转身才对准)",
"变焦生硬,推过了又拉回来",
"走动跟拍时的强烈晃动和运动模糊",
"手指或背带短暂遮挡镜头一角",
"镜头努力对焦但短暂失败,画面呼吸",
],
"environment": [
"一阵微风吹动散落的发丝或衣角",
"云影掠过,曝光随之波动",
"小动物(猫/狗/鸽子)闯入画面,主体自然回应",
"场外有人打招呼,主体转头回应",
"阳光透过树叶在脸上闪烁明灭",
"路人从镜头前无意穿过",
],
"subject": [
"注意到镜头,露出小而真诚的微笑后移开视线",
"说话被环境音打断,笑场",
"整理头发、拨发到耳后等无意识小动作",
"动作做到一半改变主意(伸手拿杯子又先擦了下桌子)",
],
"ending": [
"录制在中途突然切到黑屏,仿佛摄像机关闭",
"主体走出画面,镜头停留在空景两秒",
"镜头垂下拍到地面/脚,然后切断",
"有人喊拍摄者的名字,镜头晃动着转向声源时切断",
],
}
# ③ 特殊字符规范(信息类型标注)—— 来自 07-特殊字符规范.md
SYMBOL_SPEC = {
"台词": "{你好,世界}(外语需标语种:用日语说道{こんにちは})",
"音效": "<远处传来狗叫声>",
"音乐": "(背景中播放着快节奏的摇滚乐)",
"字幕": "【第一章:启程】",
}
def ai_negative_block(hard: bool = True, extra: list = ()) -> str:
"""组装「关闭 AI 默认审美」负面约束段
hard=False 时只取前 6 条(精简版,用于短提示词)
"""
items = [neg for _, neg in (AI_DEFAULT_NEGATIVES if hard else AI_DEFAULT_NEGATIVES[:6])]
items.extend([str(x) for x in (extra or []) if x])
return "【负面约束(关闭 AI 默认审美)】" + ";".join(items) + "。"
def pick_imperfect_events(n_shots: int, categories: tuple = None) -> list:
"""为 N 个镜头分配「非完美事件」(不重复;最后一条自动用收束方式)"""
import random
cats = list(categories or ("photographer", "environment", "subject"))
pool = []
for c in cats:
pool.extend(IMPERFECT_EVENTS.get(c, []))
random.shuffle(pool)
out = []
for i in range(max(0, n_shots)):
if i == n_shots - 1 and n_shots > 1:
out.append(random.choice(IMPERFECT_EVENTS["ending"]))
else:
out.append(pool[i % len(pool)] if pool else "")
return out
def imperfect_events_prompt() -> str:
"""给导演的「非完美事件」纪律(逐镜头分配、不重复、结尾收束、2 秒节奏)"""
return (
"【真实感纪律:非完美事件(每个镜头分配一个,全片不重复)】\n"
"真实拍摄必然有瑕疵。请为每个镜头挑 1 个(**一镜一个,不要堆**):\n"
"· 摄影师失误:" + ";".join(IMPERFECT_EVENTS["photographer"][:4]) + "\n"
"· 环境介入:" + ";".join(IMPERFECT_EVENTS["environment"][:4]) + "\n"
"· 主体自然行为:" + ";".join(IMPERFECT_EVENTS["subject"][:3]) + "\n"
"· **结尾必须用一种收束方式**:" + ";".join(IMPERFECT_EVENTS["ending"]) + "\n"
"另外:镜头约 2 秒一个(超过 3 秒会滑向「精心拍摄」的假感);"
"除非明确设定为手机慢动作,否则不要写慢动作。"
)
def check_abstract_words(text: str) -> list:
"""自检:扫出会引发 AI 感的抽象词(返回命中列表)"""
BAD = ["美丽", "唯美", "高级感", "大片感", "史诗", "震撼", "精致", "梦幻", "氛围感"]
return [w for w in BAD if w in str(text or "")]
# ════════════════════════════════════════════════════════════
# ★ 设备美学缺陷参数包(9 个)—— 2026-10-04 研究落地
# 来源:camera-aesthetics.md(原文存 docs/realism/)
#
# 核心原则:**想要某种年代质感,就完整描述那个设备的物理缺陷,缺陷即真实。**
# 与 STYLE_PACKS 的关系:
# · STYLE_PACKS = 抽象风格取向(自拍/纪录片/电影感…)
# · DEVICE_PACKS = **具体设备的物理缺陷**(DV/VHS/胶片/手机/GoPro…)
# 两者互补,可叠加。
# ════════════════════════════════════════════════════════════
DEVICE_PACKS: dict = {
"dv_2000s": {
"name": "2000年代初消费级DV摄像机",
"fit": "家庭录像、朋友随拍、千禧年怀旧、韩日老社区题材",
"visual": ["中等数字压缩伪影", "褪色的色彩,柔和的对比度", "轻微的传感器噪点",
"轻微的滚动快门", "480p 到轻微高清之间的分辨率感", "高光容易过曝,暗部发灰"],
"operation": ["强烈的手持抖动,不完美的构图", "频繁的自动对焦搜索",
"镜头呼吸(对焦时视角轻微变化)", "在阳光和阴影间移动时的曝光波动",
"偶尔的运动模糊", "变焦生硬,速度不均匀", "录制中途突然开始或结束"],
"negative": "不要三脚架式的绝对稳定。不要电影化的摄像机移动。不要现代色彩分级。",
},
"vhs_8090s": {
"name": "80至90年代VHS家用摄像机",
"fit": "童年录像、生日聚会、更旧的怀旧感、复古恐怖",
"visual": ["明显的磁带噪点和扫描线", "色彩溢出(红色尤其容易晕开)",
"底部时间码或日期戳", "画面边缘轻微的几何畸变",
"低分辨率,细节模糊", "磁带损伤造成的偶尔画面撕裂或跳帧"],
"operation": ["沉重机身带来的缓慢笨重移动", "手动变焦的顿挫感",
"白平衡偏移(室内偏橙,室外偏蓝)", "逆光时人物完全剪影化"],
"negative": "不要数字锐利感。不要三脚架式的绝对稳定。不要高动态范围。",
},
"film_8mm": {
"name": "8mm/Super 8 胶片",
"fit": "更久远的家庭记忆、梦境感、婚礼老片、艺术短片",
"visual": ["明显的胶片颗粒", "温暖偏黄或偏品红的色彩偏移", "画面边缘的暗角",
"灰尘、毛发、划痕偶尔闪过", "18fps 带来的轻微卡顿感",
"过曝的高光呈现柔和的光晕"],
"operation": ["每段镜头很短(胶片昂贵,3到5秒就切)", "硬切,没有转场",
"曝光靠估计,时对时错", "无声(胶片本身不录音)或后期配的环境音"],
"negative": "没有同期声对白。没有平滑运镜。没有数字清晰度。",
},
"smartphone_vertical": {
"name": "智能手机竖屏(2015年后)",
"fit": "Vlog、社交媒体感、街头偶遇、当代日常",
"visual": ["9:16 竖构图", "过度锐化和 HDR 处理感",
"电子防抖造成的果冻效应", "暗光下的涂抹感降噪",
"前置镜头的轻微广角畸变"],
"operation": ["单手持机的轻微倾斜", "走路时的上下浮动(电子防抖压制后仍残留)",
"对着镜头说话的自拍视角切换", "手指偶尔擦过镜头边缘",
"突然抬起或放下手机的开始与结束"],
"negative": "没有横屏。没有三脚架稳定。没有专业布光。",
},
"gopro": {
"name": "GoPro / 运动相机",
"fit": "第一人称视角、运动、旅行、冒险感",
"visual": ["超广角鱼眼畸变,边缘拉伸", "高饱和的默认色彩",
"强光下的镜头眩光", "水滴或灰尘沾在镜头上"],
"operation": ["胸挂或头戴的第一人称视角", "剧烈但被电子增稳压制的震动",
"呼吸和身体晃动传导到画面", "视角随头部转动突然甩动"],
"negative": "没有第三人称跟拍。没有电影构图。没有慢动作(除非明确要求)。",
},
"cctv": {
"name": "监控摄像头 / CCTV",
"fit": "悬疑、都市传说、客观记录感、伪纪录片",
"visual": ["固定高位俯视角", "低帧率(每秒约10到15帧的顿挫感)",
"单色或低饱和色彩,夜间红外模式呈灰白", "角落的时间戳持续跳动",
"低分辨率,人脸模糊难辨"],
"operation": ["完全静止,或极缓慢的预设巡航转动", "无声",
"人物不知道被拍摄,行为完全自然"],
"negative": "没有摄影师存在。没有构图意识。没有声音。没有变焦跟随主体。",
},
"doc_handheld": {
"name": "专业纪录片(手持电影机)",
"fit": "高质感纪实、人物特稿、社会观察",
"visual": ["浅景深,主体清晰背景柔和", "自然光为主,宽容度高",
"克制的调色,接近肉眼所见"],
"operation": ["有经验的手持,微晃但不失控", "跟焦偶尔慢半拍(保留这个不完美)",
"长镜头耐心观察,不急于切换", "被摄者偶尔瞥向镜头又移开"],
"negative": "没有摆拍感。没有采访灯光。没有戏剧化运镜。没有配乐驱动的剪辑节奏。",
},
"webcam": {
"name": "网络摄像头 / 视频通话",
"fit": "疫情记忆、远程对话、屏幕录制感、亲密而低保真的交流",
"visual": ["720p 以下的低清晰度", "压缩造成的块状伪影,网络波动时画面糊成马赛克",
"顶光或屏幕光打在脸上的青白色", "固定机位,仰视或平视的笔记本视角"],
"operation": ["音画偶尔不同步", "人物偶尔卡顿定格再恢复", "背景是真实的房间一角"],
"negative": "没有专业收音。没有补光灯质感。没有运镜。",
},
"flip_phone_2003": {
"name": "2003年翻盖手机摄像(CMOS低保真)",
"fit": "极度怀旧、千禧年初学生时代、赛博朋克低保真、暗角情绪短片",
"visual": ["极低分辨率(176×144 或 320×240)", "严重 JPEG 压缩块状伪影,色块化严重",
"色彩饱和度极端且偏色严重(通常偏蓝绿或偏品红)", "暗部几乎完全噪点糊死",
"画面中央清晰度尚可,边缘急剧劣化", "固定数字变焦(裁切放大)",
"超窄动态范围,高光纯白一片"],
"operation": ["单手持机,手臂伸直自拍或随意抓拍", "翻盖打开即拍,几乎没有延迟补偿",
"快门时滞极长(按下到实际拍摄有0.5到1秒延迟)",
"录像按一下开始,再按一下结束,没有预览确认",
"对焦基本靠固定焦段,前后移动自身来「对焦」",
"录制过程中镜头容易被手指或翻盖边缘遮挡"],
"negative": "没有清晰度。没有平滑运动。没有色彩准确度。没有变焦操作。",
},
}
# ════════════════════════════════════════════════════════════
# ★ 氛围词典(抽象氛围 → 具体可拍现象)
# 来源:atmosphere-dictionary.md(原文存 docs/realism/)
#
# 原则:**禁止直接使用抽象氛围词**,必须翻译成具体视觉/听觉现象,
# 每种氛围至少选 3~5 个现象组合使用。
# ════════════════════════════════════════════════════════════
ATMOSPHERE_DICT: dict = {
"温暖": {
"visual": ["午后阳光透过树叶在地面投下移动的斑驳光影", "晾衣绳上的织物在微风中缓慢摇曳",
"陶瓷杯口冒出的细微热气", "猫或狗慵懒地靠近人",
"盆栽植物摆在窗台和门口台阶上", "人物把头发拨到耳后的小动作",
"不经意的真诚微笑,随即移开视线"],
"audio": ["晨间鸟鸣", "远处小孩嬉闹声", "织物摩擦声", "猫叫", "勺子碰杯壁"],
},
"怀旧": {
"visual": ["褪色的色彩与柔和的对比度", "老式电线杆和架空电线切割天空",
"墙面斑驳的低层住宅", "停在巷口的旧自行车", "画面上的日期戳",
"人物穿着宽松水洗牛仔裤等年代服饰"],
"audio": ["远处摩托车驶过", "老风扇转动声", "模拟信号的底噪"],
},
"孤独": {
"visual": ["大面积负空间构图,人物偏居画面一角", "空旷街道上唯一移动的身影",
"荧光灯的青白色调", "便利店或地铁站的深夜灯光",
"人物长时间静止,只有环境在动(车流、雨、霓虹闪烁)", "玻璃反光中模糊的人影"],
"audio": ["空调外机嗡鸣", "脚步回声", "远处地铁进站声", "雨点打在遮阳棚上"],
},
"紧张": {
"visual": ["画面边缘偶尔闯入不明物体或人影", "光源不稳定,灯管闪烁",
"门缝、走廊尽头等纵深阴影", "手持镜头呼吸感加重,微微颤抖",
"人物频繁回头或停顿倾听"],
"audio": ["突然中断的环境音", "远处不明的金属碰撞声", "自己的呼吸声被收进麦克风"],
},
"平静": {
"visual": ["极缓慢的光线变化(云影掠过整个画面)", "水面或窗帘的规律性微动",
"长镜头,机位几乎不动", "人物做重复性动作(浇水、叠衣、剥豆)"],
"audio": ["风穿过树叶的持续沙沙声", "水滴声", "远处若有若无的生活声"],
},
"热闹": {
"visual": ["前景不断有人或车穿过镜头", "摊贩的动作细节(翻炒、找零、擦桌子)",
"蒸汽、油烟、烤炉的热浪扭曲空气",
"多层次的动态:近景手部动作、中景人流、远景车流"],
"audio": ["叫卖声", "锅铲碰撞", "多人交谈混叠", "自行车铃"],
},
"梦境": {
"visual": ["过曝的窗户白成一片", "胶片颗粒与光晕", "镜头短,硬切,时间线跳跃",
"人物对镜头挥手或大笑但无声", "逆光轮廓,面部细节丢失"],
"audio": ["无声,或只有一层模糊的环境底噪"],
},
"田园": {
"visual": ["大片稻田或麦田在风中如波浪起伏", "人物赤脚踩在湿润泥土或田埂上",
"荷叶、野花、藤蔓等植物特写", "阳光透过树叶在人物脸上流动的光斑",
"远处农舍的炊烟和黑瓦白墙", "蜻蜓点水、蝴蝶停在花上",
"人物背影渐渐走远,融入田野尽头"],
"audio": ["连续的蛙鸣和虫鸣", "风吹稻田的沙沙声", "远处公鸡打鸣", "溪流声",
"脚踏湿泥的柔软声音", "竹叶碰撞声"],
},
"夏日": {
"visual": ["树影在地面剧烈晃动,光斑密集而明亮", "人物额头和鬓角有细微汗珠",
"手中冰饮或西瓜表面凝结水珠", "风扇叶片或蒲扇的转动",
"强烈阳光造成的高光过曝和镜头眩光", "热空气扭曲远处景物",
"牵牛花、向日葵、荷叶等盛夏植物", "偶尔的云影掠过造成短暂阴凉"],
"audio": ["比任何季节都响亮的蝉鸣", "空调外机或风扇的低沉嗡鸣",
"冰块碰杯的清脆声", "远处孩童的嬉笑和泼水声"],
},
"通勤": {
"visual": ["密集的人流与单个主体形成对比", "从室内到室外/地上到地下的曝光剧烈变化",
"呼出的白气(秋冬)或额头的汗(盛夏)", "低头看手机、翻包找卡等碎片化动作",
"车窗玻璃上反射的模糊面容", "通勤者独特的身体语言(缩脖子、拉围巾)",
"地铁隧道中快速掠过的灯光管"],
"audio": ["广播模糊的播报声", "列车进站的轰鸣和刹车尖叫", "闸机蜂鸣声",
"脚步匆忙的节奏", "车门气动声", "隧道风噪声"],
},
"青春": {
"visual": ["漫无目的的闲逛或长时间坐着发呆", "手部小动作(拨弄衣角、卷头发、转笔)",
"靠在墙边、栏杆上,重心随意切换", "低头笑、抬头看天、把脸埋进手臂",
"旧电扇转头吹动刘海和衣领", "地面上的影子被拉长,时间缓慢推移",
"零星对话,说话间隙被环境填满"],
"audio": ["远处收音机或电视的模糊声音", "老旧风扇的转动声", "冰棍棒折断声",
"偶尔的笑声然后继续安静"],
},
}
def device_pack_block(key: str, include_negative: bool = True) -> str:
"""设备缺陷参数包 → 提示词片段(可直接粘进「摄像风格」段)"""
p = DEVICE_PACKS.get((key or "").strip())
if not p:
return ""
out = [f"【设备美学:{p['name']}】"]
out.append("画面特征:" + ";".join(p["visual"]))
out.append("操作特征:" + ";".join(p["operation"]))
if include_negative and p.get("negative"):
out.append("负面约束:" + p["negative"])
return "\n".join(out)
def atmosphere_block(keys, n_each: int = 4) -> str:
"""氛围 → 具体现象片段(每种氛围取 n 个视觉 + 全部听觉的前若干个)
keys 可以是 str 或 list;未命中则原样返回空串(调用方可自行翻译)。
用法原则:**不要直接写抽象氛围词,用这些具体现象替代**。
"""
if isinstance(keys, str):
keys = [keys]
out = []
for k in (keys or []):
a = ATMOSPHERE_DICT.get(str(k).strip())
if not a:
continue
vis = a["visual"][:max(1, n_each)]
aud = a["audio"][:max(1, n_each // 2)]
out.append(f"【{k}(用具体现象替代)】视觉:" + ";".join(vis)
+ ("。听觉:" + ";".join(aud) if aud else ""))
return "\n".join(out)
def assign_device_defects(n_shots: int) -> list:
"""按「缺陷不重复原则」给每个镜头分配**不同类型**的摄像机缺陷
来源:camera-aesthetics.md 进阶用法 —— 同一提示词里每镜的缺陷类型不重复:
对焦 → 构图 → 抖动 → 曝光 → 反应延迟 → 呼吸感 → 录制中断
"""
seq = [
"自动对焦在前景和背景之间搜索", # 对焦缺陷
"构图偏离中心,操作者跟不上主体", # 构图缺陷
"跟拍晃动 + 运动模糊", # 抖动缺陷
"进入阴影时曝光波动", # 曝光缺陷
"镜头慢了半拍才跟上动作", # 反应延迟
"自然的镜头呼吸和焦距漂移", # 呼吸感
]
out = []
for i in range(max(0, n_shots)):
if i == n_shots - 1 and n_shots > 1:
out.append("画面突然切到黑屏(录制中断)") # 结尾固定收束
else:
out.append(seq[i % len(seq)])
return out
def focus_path_hint() -> str:
"""「自动对焦路径指定法」提示(效果翻倍的关键技巧)
不要笼统写"自动对焦搜索",要给出**对焦起点和终点**。
"""
return ("【对焦路径指定(关键技巧)】不要笼统写「自动对焦搜索」——"
"要明确写出对焦的起点和终点,模型才知道对焦该在哪两个物体之间走:\n"
" ❌ 自动对焦反复搜索\n"
" ✅ 自动对焦在她脸部和她手中的西瓜之间来回切换\n"
" ✅ 自动对焦从远处山影漂移到她后背再拉回远处")
@@ -0,0 +1,376 @@
"""
AVE speed_ramp — 变速卡点引擎
功能:
1. 对视频/音频施加匀速变速
2. 缓变速度曲线: 将视频分段, 每段不同速度, 组合成渐变效果
3. 卡点策略集成: 根据能量/节拍密度生成动态速度曲线
核心: FFmpeg setpts(视频) + atempo(音频) 滤波器链
用法:
from composer.speed_ramp import speed_ramp, speed_curve
# 匀速 0.8x
speed_ramp("input.mp4", "slow.mp4", speed=0.8)
# 缓变 0.7→1.0→1.3 (6段渐变)
speed_ramp("input.mp4", "ramp.mp4", curve=[0.7, 1.0, 1.3], segments=6)
# 节拍驱动的变速 (高能段加速, 低能段减速)
speed_by_energy("input.mp4", beat_info={"segments": [...]}, output="energy_ramp.mp4")
"""
import sys, os; sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import json
import os
import subprocess
import tempfile
from pathlib import Path
from typing import Optional
from lib.logger import get_logger
from lib.ffmpeg import FFMPEG # noqa: E402 (FFmpeg 绝对路径)
logger = get_logger("speed_ramp")
# ── 常量 ──────────────────────────────────────────────────
# atempo 支持的范围: 0.5~2.0 (单次)
# 需要更极端的变速时, 级联多个 atempo
ATEMPO_MIN = 0.5
ATEMPO_MAX = 2.0
# ── 公共 API ──────────────────────────────────────────────
def speed_ramp(
input_path: str,
output_path: str = "speed_ramp.mp4",
speed: float = 1.0,
curve: list[float] | None = None,
segments: int = 6,
keep_audio: bool = True,
) -> str:
"""
变速处理
参数:
input_path: 输入视频路径
output_path: 输出视频路径
speed: 匀速倍率 (curve 为空时使用)
curve: 缓变速度曲线 [起点倍率, ..., 终点倍率] (优先于 speed)
例: [0.7, 1.0, 1.3] 表示从 0.7x 渐变到 1.3x
segments: 缓变分段数 (越多越平滑, 默认 6)
keep_audio: 是否保留音频 (默认 True)
返回: output_path
"""
if not os.path.exists(input_path):
raise FileNotFoundError(f"输入文件不存在: {input_path}")
duration = _get_duration(input_path)
if curve and len(curve) >= 2:
return _apply_curve(input_path, output_path, curve, segments, duration, keep_audio)
else:
return _apply_constant(input_path, output_path, speed, duration, keep_audio)
def speed_by_energy(
input_path: str,
output_path: str = "energy_ramp.mp4",
beat_segments: list[dict] | None = None,
base_speed: float = 1.0,
high_speed: float = 1.5,
low_speed: float = 0.7,
keep_audio: bool = True,
) -> str:
"""
按节拍能量生成变速曲线
beat_segments 格式: [{"start": 0.0, "end": 2.0, "beats": 4, "energy": 0.8}, ...]
高能量(>0.6) → 加速, 低能量(<0.3) → 减速, 中间 → 基础速度
返回: output_path
"""
if not os.path.exists(input_path):
raise FileNotFoundError(f"输入文件不存在: {input_path}")
if not beat_segments:
logger.warning("无节拍段落, 回落匀速")
return _apply_constant(input_path, output_path, base_speed, _get_duration(input_path), keep_audio)
# 按能量生成每段速度
temp_dir = tempfile.mkdtemp(prefix="ave_spd_")
seg_files = []
total_duration = _get_duration(input_path)
for idx, seg in enumerate(beat_segments):
start = seg["start"]
end = seg["end"]
dur = end - start
if dur < 0.3:
continue
# 根据能量决定速度 (线性插值)
energy = seg.get("energy", 0.5)
if energy > 0.6:
spd = base_speed + (high_speed - base_speed) * min((energy - 0.6) / 0.4, 1.0)
elif energy < 0.3:
spd = low_speed + (base_speed - low_speed) * (energy / 0.3)
else:
spd = base_speed
spd = max(ATEMPO_MIN, min(ATEMPO_MAX, spd))
seg_out = os.path.join(temp_dir, f"seg_{idx:03d}.mp4")
_apply_constant(input_path, seg_out, spd, dur, keep_audio, ss=start, t=dur)
seg_files.append(seg_out)
if not seg_files:
shutil.rmtree(temp_dir, ignore_errors=True)
return _apply_constant(input_path, output_path, base_speed, total_duration, keep_audio)
# 拼接
_concat_segments(seg_files, output_path, keep_audio)
import shutil
shutil.rmtree(temp_dir, ignore_errors=True)
logger.info(f"✅ 能量变速完成: {output_path} ({len(seg_files)} 段)")
return output_path
def make_energy_curve(
beat_info: dict,
base_speed: float = 1.0,
high_speed: float = 1.5,
low_speed: float = 0.7,
) -> list[tuple[float, float, float]]:
"""
从节拍检测结果生成能量驱动速度曲线
返回: [(start_sec, end_sec, speed), ...]
"""
segments = beat_info.get("segments", [])
if not segments:
return [(0, beat_info.get("total_duration", 30), base_speed)]
# 计算每段的归一化能量 (基于拍点密度)
max_beats = max(s.get("beats", 0) for s in segments) or 1
curves = []
for s in segments:
dur = s["end"] - s["start"]
if dur <= 0:
continue
# 拍点密度 → 能量
density = s.get("beats", 0) / max(max_beats, 1)
energy = max(0.0, min(1.0, density))
if energy > 0.6:
spd = base_speed + (high_speed - base_speed) * min((energy - 0.6) / 0.4, 1.0)
elif energy < 0.3:
spd = low_speed + (base_speed - low_speed) * (energy / 0.3)
else:
spd = base_speed
spd = max(ATEMPO_MIN, min(ATEMPO_MAX, spd))
curves.append((s["start"], s["end"], spd))
return curves
# ── 内部实现 ──────────────────────────────────────────────
def _apply_constant(
input_path: str,
output_path: str,
speed: float,
duration: float | None = None,
keep_audio: bool = True,
ss: float = 0.0,
t: float | None = None,
) -> str:
"""匀速变速"""
speed = max(ATEMPO_MIN, min(ATEMPO_MAX, speed))
vf = f"setpts={1/speed}*PTS"
cmd = [FFMPEG, "-y"]
# 如果指定了裁剪区间
if ss > 0:
cmd += ["-ss", str(ss)]
cmd += ["-i", input_path]
if t is not None:
cmd += ["-t", str(t)]
if keep_audio:
# atempo 不支持超出 [0.5, 2.0], 级联处理
af = _build_atempo_filter(speed)
# ⚠️ af 自带输入/输出标签(`[0:a]atempo=…[a]`),不能再拼一个 [a]
# (否则 filter 图非法 → 音频编码 0 字节,输出只有画面)
cmd += ["-filter_complex", f"{vf}[v];{af}"]
cmd += ["-map", "[v]", "-map", "[a]"]
else:
cmd += ["-filter_complex", f"{vf}[v]", "-map", "[v]", "-an"]
cmd += ["-c:v", "libx264", "-preset", "fast", "-crf", "23",
"-c:a", "aac", "-b:a", "192k", "-pix_fmt", "yuv420p", output_path]
try:
subprocess.run(cmd, capture_output=True, timeout=300, check=True)
logger.debug(f" 匀速 {speed:.2f}x → {output_path}")
except subprocess.CalledProcessError as e:
logger.warning(f" 变速失败: {e.stderr[:200]}")
# 回落: 不变速
subprocess.run([FFMPEG, "-y", "-i", input_path,
"-c", "copy", output_path], capture_output=True, timeout=60, check=True)
return output_path
def _apply_curve(
input_path: str,
output_path: str,
curve: list[float],
num_segments: int,
total_duration: float,
keep_audio: bool,
) -> str:
"""缓变速度曲线: 将输入切成 N 段, 每段按曲线插值变速, 再拼接"""
# 生成每段的速度
speeds = _interpolate_curve(curve, num_segments)
seg_duration = total_duration / num_segments
temp_dir = tempfile.mkdtemp(prefix="ave_curve_")
seg_files = []
for idx, spd in enumerate(speeds):
seg_start = idx * seg_duration
seg_out = os.path.join(temp_dir, f"seg_{idx:03d}.mp4")
_apply_constant(input_path, seg_out, spd, seg_duration,
keep_audio, ss=seg_start, t=seg_duration)
seg_files.append(seg_out)
# 拼接
_concat_segments(seg_files, output_path, keep_audio)
import shutil
shutil.rmtree(temp_dir, ignore_errors=True)
logger.info(f"✅ 曲线变速完成: {output_path} ({len(speeds)} 段, "
f"速度 {speeds[0]:.2f}→{speeds[-1]:.2f})")
return output_path
def _interpolate_curve(curve: list[float], num_segments: int) -> list[float]:
"""在曲线控制点之间线性插值"""
if len(curve) >= num_segments:
return curve[:num_segments]
# 在 curve 点之间均匀插值到 num_segments 个点
result = []
n_controls = len(curve) - 1
for i in range(num_segments):
# 映射到 [0, n_controls] 区间
pos = i / (num_segments - 1) * n_controls if num_segments > 1 else 0
idx_low = int(pos)
idx_high = min(idx_low + 1, n_controls)
frac = pos - idx_low
# 线性插值
val = curve[idx_low] * (1 - frac) + curve[idx_high] * frac
result.append(max(ATEMPO_MIN, min(ATEMPO_MAX, val)))
return result
def _build_atempo_filter(speed: float) -> str:
"""构建 atempo 滤波器链 (支持 [0.5, 2.0], 超范围级联)
atempo 单次只能 [0.5, 2.0].
例如 speed=3.0 → atempo=2.0, atempo=1.5
例如 speed=0.4 → atempo=0.5, atempo=0.8
"""
if ATEMPO_MIN <= speed <= ATEMPO_MAX:
return f"[0:a]atempo={speed}[a]"
# 级联: 分解为多个在范围内的 atempo
factors = []
remaining = speed
if speed > ATEMPO_MAX:
while remaining > ATEMPO_MAX:
factors.append(ATEMPO_MAX)
remaining /= ATEMPO_MAX
if remaining >= ATEMPO_MIN:
factors.append(remaining)
elif speed < ATEMPO_MIN:
while remaining < ATEMPO_MIN:
factors.append(ATEMPO_MIN)
remaining /= ATEMPO_MIN
if remaining <= ATEMPO_MAX:
factors.append(remaining)
if not factors:
factors = [speed]
# 构建级联链: [0:a]atempo=f0[a0];[a0]atempo=f1[a1];...
chain = []
prev = "0:a"
for i, f in enumerate(factors):
label = f"a{i}"
chain.append(f"[{prev}]atempo={f:.4f}[{label}]")
prev = label
return ";".join(chain)
def _concat_segments(seg_files: list[str], output_path: str, keep_audio: bool):
"""拼接变速后的视频段"""
if len(seg_files) == 1:
subprocess.run([FFMPEG, "-y", "-i", seg_files[0],
"-c", "copy", output_path], capture_output=True, timeout=60, check=True)
return
# 用 concat demuxer
concat_file = tempfile.NamedTemporaryFile(suffix=".txt", delete=False, mode="w").name
with open(concat_file, "w") as f:
for sf in seg_files:
f.write(f"file '{os.path.abspath(sf)}'\n")
cmd = [FFMPEG, "-y", "-f", "concat", "-safe", "0", "-i", concat_file]
if keep_audio:
cmd += ["-c:v", "libx264", "-preset", "fast", "-crf", "23",
"-c:a", "aac", "-b:a", "192k", "-pix_fmt", "yuv420p"]
else:
cmd += ["-c:v", "libx264", "-preset", "fast", "-crf", "23", "-an"]
cmd += [output_path]
try:
subprocess.run(cmd, capture_output=True, timeout=300, check=True)
except subprocess.CalledProcessError as e:
logger.warning(f" 拼接失败, 回落 concat: {e.stderr[:200]}")
# 回落: filter concat
concat_filter = "".join(f"[{i}:v]" for i in range(len(seg_files)))
cmd2 = [FFMPEG, "-y"]
for sf in seg_files:
cmd2 += ["-i", sf]
cmd2 += ["-filter_complex",
f"{concat_filter}concat=n={len(seg_files)}:v=1{'a=1' if keep_audio else '':1}[v]"
+ (f";{concat_filter.replace(':v',':a')}concat=n={len(seg_files)}:v=0:a=1[a]"
if keep_audio else ""),
"-map", "[v]"]
if keep_audio:
cmd2 += ["-map", "[a]"]
cmd2 += ["-c:v", "libx264", "-preset", "fast", "-crf", "23",
"-c:a", "aac", "-b:a", "192k", "-pix_fmt", "yuv420p", output_path]
subprocess.run(cmd2, capture_output=True, timeout=300, check=True)
os.unlink(concat_file)
def _get_duration(path: str) -> float:
"""获取媒体文件时长(秒)
用 lib.ffmpeg.probe_duration(ffmpeg -i 解析)——
本机 ffprobe 实为 ffmpeg 的符号链接,不支持 -show_entries(会静默失败)
"""
try:
from lib.ffmpeg import probe_duration
d = probe_duration(path)
return d if d > 0 else 30.0
except Exception:
return 30.0
@@ -0,0 +1,114 @@
meta:
project_id: "daoist_001"
style: knowledge_lecture
voice:
provider: aliyun
voice_id: cosyvoice-v3.5-plus-bailian-f92cac2a41ce4fc78153c6097755a60e
default_emotion: 专业沉稳
bgm:
provider: chord_pad
genre: cinematic
tempo: 70
mood: calm
output:
resolution: 1080x1920
fps: 30
segments:
- id: 1
text: "道家思想,源远流长。今天和大家分享几句道家经典金句,一起感受古人的智慧。"
duration_sec: 10
voice_emotion: 专业沉稳
bgm_section: intro
material:
source: pexels
search: "chinese landscape"
transition_in: fade_in
- id: 2
text: "上善若水,水善利万物而不争。——《道德经》最上等的善就像水一样,默默滋养万物,却从不争抢。"
duration_sec: 12
voice_emotion: 专业沉稳
bgm_section: main
material:
source: pexels
search: "water flow nature"
- id: 3
text: "飘风不终朝,骤雨不终日。——《道德经》越是猛烈的狂风暴雨,越不会持久。那些和风细雨,反而悠远绵长。"
duration_sec: 12
voice_emotion: 专业沉稳
bgm_section: main
material:
source: pexels
search: "rain nature calm"
- id: 4
text: "祸兮福之所倚,福兮祸之所伏。——《道德经》坏事中藏着好运,好事中也藏着危机。顺境莫得意,逆境莫灰心。"
duration_sec: 12
voice_emotion: 专业沉稳
bgm_section: main
material:
source: pexels
search: "yin yang balance"
- id: 5
text: "大方无隅,大器晚成。——《道德经》最宏大的方正反而没有棱角,最贵重的器物反而最后完成。真正有价值的事,需要时间沉淀。"
duration_sec: 12
voice_emotion: 专业沉稳
bgm_section: main
material:
source: pexels
search: "sunset time"
- id: 6
text: "吾生也有涯,而知也无涯。——《庄子》以有限的生命去追求无限的知识,是一件危险的事。不是不学,而是要有取舍。"
duration_sec: 12
voice_emotion: 专业沉稳
bgm_section: main
material:
source: pexels
search: "reading book wisdom"
- id: 7
text: "举世誉之而不加劝,举世非之而不加沮。——《庄子》全世界都赞美你,不会更努力;全世界都批评你,不会更沮丧。内心坚定,不因外物动摇。"
duration_sec: 12
voice_emotion: 专业沉稳
bgm_section: climax
material:
source: pexels
search: "mountain peak sunrise"
- id: 8
text: "人生天地之间,若白驹过隙,忽然而已。——《庄子》人生在天地之间,就像白色骏马闪过缝隙,转瞬即逝。珍惜当下。"
duration_sec: 12
voice_emotion: 专业沉稳
bgm_section: main
material:
source: pexels
search: "time lapse nature"
- id: 9
text: "相濡以沫,不如相忘于江湖。——《庄子》与其在干涸的车辙里用口水互相湿润,不如各自在大江大湖里自由遨游。"
duration_sec: 10
voice_emotion: 专业沉稳
bgm_section: main
material:
source: pexels
search: "lake fish swimming"
- id: 10
text: "致虚极,守静笃。——《道德经》当我们放下一切杂念,内心达到极致的虚空和宁静时,就能感受到大道的存在了。"
duration_sec: 12
voice_emotion: 专业沉稳
bgm_section: main
material:
source: pexels
search: "meditation peace"
- id: 11
text: "正身直行,众邪自息。——《淮南子》自身端正,走得正行得直,各种邪门歪道自然就会消失。修身立本。"
duration_sec: 10
voice_emotion: 专业沉稳
bgm_section: main
material:
source: pexels
search: "bamboo forest path"
- id: 12
text: "道法自然,无为而治。以上几句道家智慧,与君共勉。关注我,一起领悟更多人生智慧。"
duration_sec: 10
voice_emotion: 专业沉稳
bgm_section: outro
material:
source: pexels
search: "chinese calligraphy"
transition_out: fade_out
@@ -0,0 +1,256 @@
#!/usr/bin/env python3
"""
Demo: 漫剧视频工厂全链路流程演示 v1.0
模拟一次完整的生产流程:
灵感 → 脚本 → Gate 1(审核) → 角色适配 → Gate 2(审核) → 选BGM → Gate 3(费用确认) → 开始生产
通过此演示可以看到三道审核关卡的交互和各个模块的协同工作。
"""
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from script_schemas import ScriptBuilder, validate_script, generate_gate1_summary, generate_gate2_table, save_script
from character_registry import CharacterRegistry
from character_adapter import CharacterAdapter
from music_selector import BGMSelector
from pipeline_controller import PipelineController
def print_separator(title):
print()
print("=" * 70)
print(f" {title}")
print("=" * 70)
def main():
print_separator("漫剧视频工厂 — 全链路流程演示")
print("模拟场景: '小漫的一天' — 治愈系漫剧")
print("角色: 小漫 (示例角色,已在角色注册中心注册)")
print("路径: 漫剧 (即梦文生图)")
print()
# ── 准备 ──
print_separator("📋 第 0 步: 初始化模块")
registry = CharacterRegistry()
adapter = CharacterAdapter(registry)
bgm = BGMSelector()
pc = PipelineController()
char = registry.get_active_character()
print(f" 角色: {char.name}")
print(f" 描述: {char.description[:40]}...")
print(f" BGM 库: {len(bgm)} 首可用")
print(f" BGM 热门: {[e['name'] for e in bgm.get_trending(3)]}")
# ── 创建脚本 ──
print_separator("📝 第 1 步: 创建脚本 (输入层)")
script = ScriptBuilder.empty("小漫的一天", "小漫")
script["meta"]["source"] = "文字灵感"
ScriptBuilder.add_segment(script, {
"scene": "清晨的咖啡馆",
"description": "推开咖啡馆的门,阳光透过窗户洒进来,空气中飘着咖啡香",
"dialogue": "(旁白)每个清晨,小漫都会来到这家街角的咖啡馆。",
"emotion": "温暖平静",
"duration_sec": 8,
"camera": "wide_shot",
"transition": "fade_in",
})
ScriptBuilder.add_segment(script, {
"scene": "靠窗的座位",
"description": "她坐下来,把画板放在桌上,拿出铅笔开始构思",
"dialogue": "小漫: '今天一定要把草稿画完!'",
"emotion": "专注",
"duration_sec": 6,
"camera": "medium_shot",
"transition": "cut",
})
ScriptBuilder.add_segment(script, {
"scene": "画板上的世界",
"description": "铅笔在纸上飞舞,线条逐渐成形,一个奇妙的世界在画中展开",
"dialogue": "(旁白)在她的画笔下,每一个角落都充满了故事。",
"emotion": "愉悦",
"duration_sec": 8,
"camera": "close_up",
"transition": "zoom_in",
})
ScriptBuilder.add_segment(script, {
"scene": "傍晚的天台",
"description": "夕阳西下,天边的云彩染成金色,她看着刚完成的画",
"dialogue": "(旁白)有时候,最简单的快乐就在画笔下。",
"emotion": "满足",
"duration_sec": 10,
"camera": "wide_shot",
"transition": "dissolve",
})
errors = validate_script(script)
print(f" 脚本标题: {script['meta']['title']}")
print(f" 分镜数: {len(script['segments'])} 个")
print(f" 总时长: {script['meta']['duration_sec']}s")
print(f" 验证结果: {'✅ 通过' if not errors else '❌ 有错误: ' + str(errors)}")
# ── 创建生产任务 ──
prod = pc.create("文字灵感", "小漫的一天", "小漫的一天", "小漫", "manhua")
pc.set_script(prod.id, script)
print(f" 生产任务: {prod.id}")
# ── Gate 1: 脚本方向审核 ──
print_separator("🅰️ 第 2 步: Gate 1 — 脚本方向审核")
result = pc.submit_gate(prod.id, gate=1)
s = result.get("summary", {})
print(f" 状态: {result['state']}")
print(f" ─────────────────────────────────")
print(f" 标题: {s.get('title', '')}")
print(f" 来源: {s.get('source', '')}")
print(f" 角色: {s.get('character', '')}")
print(f" 总时长: {s.get('total_duration', 0)}s ({s.get('segment_count', 0)} 个分镜)")
print(f" 结构:")
for sc in s.get("structure", []):
print(f" #{sc['id']} {sc['scene']} ({sc['duration']}s, {sc['emotion']})")
print(f" 情绪分布: {s.get('emotion_distribution', {})}")
print(f" ─────────────────────────────────")
print(f" 📌 等待你审核: 脚本方向、风格、节奏")
print(f" ✅ approve → ❌ reject → 输入反馈")
print()
# 模拟通过
print(" >> 模拟: 审核通过 ✅")
pc.approve_gate(prod.id, gate=1)
# ── 角色适配 ──
print_separator("🔧 第 3 步: 角色适配 (核心)")
# 预览适配效果
preview = adapter.preview_adaptation(script)
print(f" 适配角色: {preview['character']}")
print(f" 角色描述: 短发齐刘海、大眼睛深棕色瞳孔、白连衣裙红蝴蝶结")
print()
for b, a in zip(preview["before"], preview["after"]):
print(f" #{b['id']} {b['scene']}")
print(f" 描述: {b['description'][:40]}...")
print(f" → {a['description'][:40]}...")
print()
# 执行适配
adapted_storyboard = adapter.adapt_to_storyboard(script)
pc.set_adapted_storyboard(prod.id, adapted_storyboard)
print(f" ✅ 适配完成: {len(adapted_storyboard)} 个分镜已注入角色信息")
# ── Gate 2: 分镜预览审核 ──
print_separator("🅱️ 第 4 步: Gate 2 — 分镜预览审核")
result = pc.submit_gate(prod.id, gate=2)
print(f" 状态: {result['state']}")
print(f" ─────────────────────────────────")
print(f" {'#':>3} | {'场景':12s} | {'时长':4s} | {'情绪':8s} | {'镜头':12s} | {'视觉风格':8s}")
print(f" ─────┼─────────────┼──────┼──────────┼──────────────┼──────────")
for seg in result.get("storyboard", []):
dur = seg.get('duration', seg.get('duration_sec', 0))
print(f" {seg['id']:3d} | {seg['scene']:12s} | {dur:3d}s | {seg['emotion']:8s} | {seg['camera']:12s} | {seg.get('visual_style', 'manhua'):8s}")
print(f" ─────────────────────────────────")
print(f" 📌 等待你审核: 逐分镜检查场景/情绪/视觉风格")
print(f" ✅ 全部通过 → ❌ 打回(说明意见)")
print()
# 模拟通过
print(" >> 模拟: 审核通过 ✅")
pc.approve_gate(prod.id, gate=2)
# ── BGM 选择 ──
print_separator("🎵 第 5 步: BGM 选择")
print(" 🔥 热门推荐:")
for bgm_entry in bgm.get_trending(3):
print(f" {bgm_entry['id']:15s} | {bgm_entry['name']:10s} | {bgm_entry['artist']:10s} | {bgm_entry['bpm']}bpm | {bgm_entry['style']}")
print()
print(" 📂 按风格浏览:")
for style in bgm.list_styles():
entries = bgm.get_by_style(style)
print(f" {style}: {', '.join(e['name'] for e in entries)}")
selected_bgm = "向云端"
bgm_config = bgm.generate_config(selected_bgm, beat_sync=False, ducking=True, volume=0.6)
pc.set_bgm(prod.id, selected_bgm, bgm_config)
print(f"\n 🎯 选择: {selected_bgm}")
print(f" 音量: {bgm_config['volume']}, 人声避让: {bgm_config['ducking']}, BPM: {bgm_config['bpm']}")
# ── Gate 3: 费用确认 ──
print_separator("💰 第 6 步: Gate 3 — 费用确认")
result = pc.submit_gate(prod.id, gate=3)
cost = result.get("cost", {})
print(f" 状态: {result['state']}")
print(f" ─────────────────────────────────")
print(f" 路径: {cost.get('path_type', 'manhua')}")
print(f" 分镜数: {cost.get('image_count', 0)}")
print(f" ─────────────────────────────────")
print(f" 图片生成: ¥{cost.get('image_cost', 0):.2f}")
print(f" 视频生成: ¥{cost.get('video_cost', 0):.2f}")
print(f" TTS 费用: ¥{cost.get('tts_cost', 0):.2f}")
print(f" BGM 费用: ¥{cost.get('bgm_cost', 0):.2f}")
print(f" ─────────────────────────────────")
print(f" ══ 总计: ¥{cost.get('total', 0):.2f} ══")
print(f" ─────────────────────────────────")
print(f" 📌 等待你确认: 费用明细确认")
print(f" ✅ 确认并执行 → ❌ 取消 → 回到分镜调整")
print()
# 模拟确认
print(" >> 模拟: 费用确认 ✅")
pc.confirm_cost(prod.id)
# ── 开始生成 ──
print_separator("🎬 第 7 步: 开始生产")
pc.start_generation(prod.id)
print(f" 状态: {pc.get(prod.id).state.value}")
# 模拟完成
pc.complete_step(prod.id, "generation")
pc.complete_step(prod.id, "composition")
# ── 最终状态 ──
print_separator("🏁 生产完成")
final = pc.get(prod.id)
print(f" 任务 ID: {final.id}")
print(f" 标题: {final.title}")
print(f" 状态: {final.state.value} ✅")
print(f" 角色: {final.character}")
print(f" BGM: {final.bgm_id}")
print(f" 创建: {final.created_at}")
print(f" 完成: {final.completed_at}")
print(f" 成本: ¥{final.cost.total:.2f}")
# ── 审核打回演示 ──
print_separator("🔄 额外演示: 审核打回流程")
prod2 = pc.create("文字灵感", "测试", "测试打回", "小漫", "manhua")
pc.set_script(prod2.id, script)
pc.submit_gate(prod2.id, gate=1)
pc.reject_gate(prod2.id, gate=1, feedback="故事节奏太慢,需要增加冲突桥段")
print(f" 任务: {prod2.id}")
print(f" 状态: {pc.get(prod2.id).state.value}")
print(f" 反馈: {pc.get(prod2.id).gate1_feedback}")
print()
print(f" 你可以:")
print(f" - 修改脚本后 resubmit (重提审核)")
print(f" - 取消任务 cancel")
# ── 清理测试数据 ──
for pid in [prod.id, prod2.id]:
path = pc._data_dir / f"{pid}.json"
if path.exists():
path.unlink()
print()
print("=" * 70)
print(" ✅ 全链路演示完成!")
print(" 📍 所有模块已通过串行测试")
print("=" * 70)
if __name__ == "__main__":
main()
@@ -0,0 +1,192 @@
"""
AVE 01_director_parser — 文案 → 导演脚本 YAML
流程:
1. 读取文案文本
2. 调用本地 oMLX (Qwen3.5) 解析 → 结构化 segments
3. 调用 schemas.py 校验
4. 输出 director_script.yaml
本地 LLM: http://localhost:8000/v1/chat/completions
"""
import sys, os; sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import json
import os
import yaml
from pathlib import Path
from typing import Optional
from lib.logger import get_logger
from .schemas import DirectorScript, validate_script
logger = get_logger("director_parser")
# 本地 oMLX 配置
OMLX_URL = "http://localhost:8000/v1/chat/completions"
OMLX_MODEL = "Qwen3.5-4B-MLX-4bit"
OMLX_API_KEY = "omlx"
def parse_script(
script_path: str,
style: str = "knowledge_lecture",
output_path: str = "director_script.yaml",
use_llm: bool = True,
) -> str:
"""
文案 → 导演脚本 YAML
- script_path: 文案文件路径 (.txt)
- style: 视频风格 (knowledge_lecture | bedtime_story | funny_talk | tech_review)
- output_path: 输出的 YAML 路径
- use_llm: 是否使用本地 LLM 解析 (False = 手动模板)
返回: 生成的 YAML 路径
"""
if not os.path.exists(script_path):
raise FileNotFoundError(f"文案文件不存在: {script_path}")
with open(script_path, "r", encoding="utf-8") as f:
script_text = f.read().strip()
if not script_text:
raise ValueError(f"文案内容为空: {script_path}")
if use_llm:
# 用本地 oMLX 解析
script_data = _parse_with_llm(script_text, style)
else:
# 手动模板: 整段文案作为单 segment
script_data = _manual_parse(script_text, style)
# Pydantic 校验
validated = validate_script(script_data)
# 输出 YAML
with open(output_path, "w", encoding="utf-8") as f:
yaml.dump(
validated.model_dump(mode="json"),
f,
allow_unicode=True,
default_flow_style=False,
sort_keys=False,
)
logger.info(f"导演脚本已生成: {output_path} ({len(validated.segments)} 段)")
return output_path
def _parse_with_llm(script_text: str, style: str) -> dict:
"""调用本地 oMLX 解析文案为结构化数据"""
import httpx
prompt = f"""你是一个视频导演脚本生成器。将以下文案解析为结构化导演脚本。
风格: {style}
要求:
1. 按自然语义停顿分段 (每段6-30秒)
2. 为每段分配情绪描述
3. 为每段设计视觉素材搜索词 (中文关键词)
4. 为每段指定 BGM 段落 (intro/main/climax/outro)
5. BGM结构: intro(0-8s) → main(8-50s) → climax(50-65s) → outro(65-75s)
6. 输出纯 JSON,不要 markdown 包裹
文案:
{script_text}
输出 JSON 格式:
{{
"meta": {{
"style": "{style}",
"voice": {{"provider": "volcano", "voice_id": "default", "default_emotion": "专业沉稳"}},
"bgm": {{
"genre": "cinematic", "tempo": 110, "mood": "inspirational",
"structure": [{{"section": "intro"}}, {{"section": "main"}}, {{"section": "climax"}}, {{"section": "outro"}}]
}},
"output": {{"resolution": "1080x1920", "fps": 30}}
}},
"segments": [
{{
"id": 1,
"text": "分段文案",
"duration_sec": 8,
"voice_emotion": "开场悬念,压低声音",
"camera": "slow_zoom_in",
"bgm_section": "intro",
"material": {{"source": "pexels", "search": "中文搜索关键词"}},
"subtitles": true
}}
]
}}"""
try:
resp = httpx.post(
OMLX_URL,
json={
"model": OMLX_MODEL,
"messages": [{"role": "user", "content": prompt}],
"temperature": 0.3,
"max_tokens": 4096,
},
headers={"Authorization": f"Bearer {OMLX_API_KEY}"},
timeout=120,
)
resp.raise_for_status()
result = resp.json()
content = result["choices"][0]["message"]["content"]
# 清理可能的 markdown 包裹
content = content.strip()
if content.startswith("```"):
content = content.split("\n", 1)[1]
content = content.rsplit("```", 1)[0]
if content.startswith("json"):
content = content[4:]
data = json.loads(content)
data["meta"]["style"] = style
logger.info(f"oMLX 解析完成: {len(data.get('segments', []))} 段")
return data
except httpx.ConnectError:
logger.warning("oMLX 未运行,降级到手动模板模式")
return _manual_parse(script_text, style)
except (json.JSONDecodeError, KeyError) as e:
logger.warning(f"LLM 解析失败 ({e}),降级到手动模板")
return _manual_parse(script_text, style)
def _manual_parse(script_text: str, style: str) -> dict:
"""手动模板: 整段文案作为一个 segment(降级方案)"""
return {
"meta": {
"style": style,
"voice": {"provider": "volcano", "voice_id": "default", "default_emotion": "专业沉稳"},
"bgm": {
"genre": "cinematic" if style != "funny_talk" else "upbeat",
"tempo": 110,
"mood": "inspirational",
"structure": [
{"section": "intro"},
{"section": "main"},
{"section": "climax"},
{"section": "outro"},
],
},
"output": {"resolution": "1080x1920", "fps": 30},
},
"segments": [
{
"id": 1,
"text": script_text,
"duration_sec": max(15, min(len(script_text) * 0.2, 120)),
"voice_emotion": "正常讲述",
"camera": "static",
"bgm_section": "main",
"material": {"source": "pexels", "search": style},
"subtitles": True,
}
],
}
@@ -0,0 +1,131 @@
"""
AVE 01_director_parser — 导演脚本解析器
职责:
1. schemas.py: YAML 结构定义 + Pydantic 校验
2. parser.py: 文案 → director_script.yaml (调用本地 oMLX)
本地 LLM (oMLX) 路径:
http://localhost:8000/v1/chat/completions
模型: Qwen3.5-4B-MLX-4bit
API Key: omlx
"""
import sys, os; sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from pydantic import BaseModel, Field, field_validator
from typing import Optional
from enum import Enum
class StyleEnum(str, Enum):
knowledge_lecture = "knowledge_lecture"
bedtime_story = "bedtime_story"
funny_talk = "funny_talk"
tech_review = "tech_review"
class VoiceProvider(str, Enum):
volcano = "volcano"
aliyun = "aliyun"
class BGMSection(BaseModel):
section: str
description: Optional[str] = None
class BGMMeta(BaseModel):
provider: str = "ace_step"
genre: str = "cinematic"
tempo: int = 110
mood: str = "inspirational"
structure: list[BGMSection] = []
@field_validator("tempo")
@classmethod
def tempo_range(cls, v):
if v < 40 or v > 220:
raise ValueError("tempo 应在 40-220 之间")
return v
class OutputMeta(BaseModel):
resolution: str = "1080x1920"
fps: int = 30
@field_validator("resolution")
@classmethod
def valid_resolution(cls, v):
valid = ["1080x1920", "1920x1080", "720x1280", "1280x720"]
if v not in valid:
raise ValueError(f"分辨率必须是 {valid} 之一")
return v
class MaterialSource(str, Enum):
pexels = "pexels"
ai_generate = "ai_generate"
local = "local"
class MaterialConfig(BaseModel):
source: MaterialSource = MaterialSource.pexels
search: str = ""
transition_in: str = "fade_in"
transition_out: str = "dissolve"
fallback: bool = True
class CharacterRef(BaseModel):
"""角色引用(定妆照锁定)"""
name: str = Field(description="角色名")
description: str = Field(description="角色描述块(跨场景一致性注入)")
grid_path: Optional[str] = Field(default="", description="定妆照网格图路径")
lip_sync: bool = Field(default=False, description="是否对角色视频做 LipSync")
class AvatarConfig(BaseModel):
image_url: Optional[str] = None
provider: str = "wan2.2_s2v"
default_position: str = "bottom_right"
class Segment(BaseModel):
id: int = Field(ge=1)
text: str = Field(min_length=1)
duration_sec: float = Field(default=10, ge=1, le=300)
voice_emotion: str = "正常讲述"
camera: str = "static"
bgm_section: str = "main"
avatar: Optional[dict] = None
material: MaterialConfig = MaterialConfig()
subtitles: bool = True
character_ref: Optional[str] = Field(default=None, description="引用角色名(对应 meta.character_refs)")
class VoiceMeta(BaseModel):
provider: VoiceProvider = VoiceProvider.volcano
voice_id: str = "default"
default_emotion: str = "专业沉稳"
class Meta(BaseModel):
project_id: str = ""
style: StyleEnum = StyleEnum.knowledge_lecture
voice: VoiceMeta = VoiceMeta()
bgm: BGMMeta = BGMMeta()
output: OutputMeta = OutputMeta()
avatar: Optional[AvatarConfig] = None
material: dict = {"fallback": True}
character_refs: list[CharacterRef] = Field(default=[], description="角色引用列表(定妆照一致性)")
class DirectorScript(BaseModel):
"""完整导演脚本"""
meta: Meta = Meta()
segments: list[Segment] = Field(min_length=1)
def validate_script(data: dict) -> DirectorScript:
"""校验并返回结构化的导演脚本"""
return DirectorScript.model_validate(data)
@@ -0,0 +1,74 @@
meta:
project_id: ''
style: knowledge_lecture
voice:
provider: volcano
voice_id: default
default_emotion: 专业沉稳
bgm:
provider: ace_step
genre: cinematic
tempo: 110
mood: inspirational
structure:
- section: intro
description: null
- section: main
description: null
- section: climax
description: null
- section: outro
description: null
output:
resolution: 1080x1920
fps: 30
avatar: null
material:
fallback: true
segments:
- id: 1
text: '# AVE 演示脚本:一个小笑话
# 用于验证 MVP 链路 (文案→人声→素材→合成)
标题: "一只会打太极的猫"
今天我要讲一个真实的故事。
我家养了一只橘猫,名字叫"大橘"。
大橘有个奇怪的爱好——每天早上六点,它会准时蹲在阳台的瑜伽垫上,开始打太极。
你没听错,打太极。
一开始我以为它在伸懒腰,后来发现不对劲。它的动作实在太标准了——野马分鬃、白鹤亮翅、搂膝拗步,一套下来行云流水。
更离谱的是,如果你在它练功的时候打扰它,它会瞪你一眼,然后换个地方继续打。
后来我调了监控才发现,它是在偷看我奶奶晨练,看了一年,学会了。
现在大橘在我们小区已经出名了,每天早上都有人专门来看它打太极。
所以下次有人说"猫不如狗聪明",你就告诉他:有一种猫,比你还会养生。'
duration_sec: 68.8
voice_emotion: 正常讲述
camera: static
bgm_section: main
avatar: null
material:
source: pexels
search: knowledge_lecture
transition_in: fade_in
transition_out: dissolve
fallback: true
subtitles: true
@@ -0,0 +1,75 @@
"""
directors — 导演系统注册表(替换点①)
========================================
统一入口:任何导演系统在此注册,下游通过 `get_director(id)` 取用。
接入新导演(3 步):
1. directors/<新导演>.py 实现 BaseDirector
2. 本文件 import + 加进 _DIRECTORS
3. 刷新前端,「🎬 导演创作」页的下拉里就会出现
"""
from __future__ import annotations
import logging
import sys
from pathlib import Path
_SCRIPTS = Path(__file__).resolve().parent.parent
if str(_SCRIPTS) not in sys.path:
sys.path.insert(0, str(_SCRIPTS))
from directors.base import BaseDirector # noqa: E402,F401
from directors.legacy_scene_planner import LegacyScenePlanner # noqa: E402
log = logging.getLogger("directors")
# ── 已注册的导演(后续接入的在此追加)──────────────────
_DIRECTORS: list[BaseDirector] = [LegacyScenePlanner()]
# v5.2(ai-film-skills):技能文档就位后自动注册
try:
from directors.storyboard_v52 import StoryboardV52
_DIRECTORS.append(StoryboardV52())
except Exception as _e: # 技能文档未下载 / 模块未就绪
log.debug(" storyboard_v52 未注册: %s", _e)
# Seedance 导演方法论(精编移植,skill 文档就位后自动注册)
try:
from directors.seedance_director import SeedanceDirector
_DIRECTORS.append(SeedanceDirector())
except Exception as _e:
log.debug(" seedance_director 未注册: %s", _e)
def all_directors() -> list[BaseDirector]:
return list(_DIRECTORS)
def get_director(director_id: str) -> BaseDirector | None:
for d in _DIRECTORS:
if d.id == director_id:
return d
return None
def default_director() -> BaseDirector:
"""默认导演(第一个 ready 的)"""
for d in _DIRECTORS:
if d.status == "ready" and getattr(d, "available", True):
return d
return _DIRECTORS[0]
def list_info() -> list[dict]:
"""给前端:全部导演 + 可用性"""
out = []
for d in _DIRECTORS:
info = d.info()
info["available"] = bool(getattr(d, "available", True))
out.append(info)
return out
__all__ = ["BaseDirector", "LegacyScenePlanner", "all_directors",
"get_director", "default_director", "list_info"]
@@ -0,0 +1,163 @@
"""
directors._shot_builder — 公共镜头构建器(各 skill 型导演共用)
职责:把 LLM 产出的 JSON 数组 → 统一的 Shot 列表(含完整镜头契约/表演/声音/技术需求)。
被 storyboard_v52(v5.2 技能)与 seedance_director(Seedance 方法论)共用,
保证不同导演产出同一套 Schema,下游无感知。
"""
from __future__ import annotations
import sys
from pathlib import Path
from typing import Any
_SCRIPTS = Path(__file__).resolve().parent.parent
if str(_SCRIPTS) not in sys.path:
sys.path.insert(0, str(_SCRIPTS))
from schemas import (GlobalAnchors, Shot, ShotContract, # noqa: E402
Transition, Speed, AudioConfig, SFX, ExpressionDetail)
def safe_dict(v) -> dict:
return v if isinstance(v, dict) else {}
def norm_type(t: Any) -> str:
t = (str(t) if t else "").strip().lower()
aliases = {"talk": "talking_head", "b_roll": "background", "broll": "background",
"scene": "action", "environment": "background"}
v = aliases.get(t, t or "action")
return v if v in ("action", "talking_head", "background", "vfx", "comfyui", "3d_character") else "action"
def parse_expression(it: dict) -> ExpressionDetail | None:
e = safe_dict(it.get("expression_detail"))
if not e:
return None
eyes = (e.get("eyes") or "").strip()
mouth = (e.get("mouth") or "").strip()
brow = (e.get("brow") or "").strip()
if not (eyes or mouth or brow):
return None
return ExpressionDetail(eyes=eyes or None, mouth=mouth or None, brow=brow or None)
def parse_audio(it: dict) -> AudioConfig | None:
cue = (it.get("audio_cue") or "").strip()
sfx_raw = it.get("sfx") or []
sfx: list[SFX] = []
if isinstance(sfx_raw, list):
for i, s in enumerate(sfx_raw, 1):
d = safe_dict(s)
if not d:
continue
try:
at = float(d.get("at_second") or 0)
except Exception:
at = 0.0
sfx.append(SFX(id=f"sfx_{i}", at_second=at,
volume=float(d.get("volume") or 0.4),
desc=(d.get("desc") or "").strip()))
if not cue and not sfx:
return None
return AudioConfig(audio_cue=cue or None, sfx=sfx)
def parse_transition(v) -> Transition:
d = safe_dict(v)
t = (d.get("type") or "cut").strip().lower()
if t not in ("cut", "dissolve", "fade", "wipe"):
t = "cut"
try:
dur = float(d.get("duration") or 0)
except Exception:
dur = 0.0
return Transition(type=t, duration=dur)
def parse_speed(v) -> Speed:
d = safe_dict(v)
try:
f = float(d.get("factor") or 1.0)
except Exception:
f = 1.0
ramp = (d.get("ramp") or "").strip()
return Speed(factor=f, ramp=ramp or None)
def parse_tech_requirements(it: dict) -> dict | None:
"""技术需求声明(透传 + 基本校验),供技术优选层消费"""
tr = it.get("tech_requirements")
if not isinstance(tr, dict):
return None
cont = safe_dict(tr.get("continuity"))
allowed = ("must", "prefer", "none")
out = {
"continuity": {k: (str(cont.get(k, "none")).strip().lower()
if str(cont.get(k, "none")).strip().lower() in allowed else "none")
for k in ("character", "wardrobe", "location", "expression", "props")},
"chain_from_prev": bool(tr.get("chain_from_prev")),
"motion_level": str(tr.get("motion_level") or "small").strip().lower(),
"camera_difficulty": str(tr.get("camera_difficulty") or "fixed").strip().lower(),
"needs": [str(n).strip() for n in (tr.get("needs") or []) if str(n).strip()],
}
if out["motion_level"] not in ("static", "small", "medium", "large"):
out["motion_level"] = "small"
if out["camera_difficulty"] not in ("fixed", "simple", "complex"):
out["camera_difficulty"] = "fixed"
return out
def build_shots_from_items(items: list, anchors: GlobalAnchors,
shot_id_prefix: str = "shot") -> list[Shot]:
"""LLM JSON 数组 → Shot 列表(统一契约)
各导演只需产出相同字段结构的 JSON,本函数负责解析、归一化与容错。
"""
from directors.base import norm_duration
shots: list[Shot] = []
for i, it in enumerate(items, 1):
if not isinstance(it, dict):
continue
text = (it.get("text") or "").strip()
tech_req = parse_tech_requirements(it)
chain = bool((tech_req or {}).get("chain_from_prev"))
# 出场人物(戏内代号):只保留锚点演员表里存在的,避免 AI 写不存在的代号
_valid_slots = {m.slot for m in (anchors.effective_cast() if hasattr(anchors, "effective_cast") else [])}
_raw_slots = it.get("cast_in_shot") or it.get("cast_slots") or []
if isinstance(_raw_slots, str):
_raw_slots = [_raw_slots]
cast_slots = [str(x).strip() for x in _raw_slots
if str(x).strip() and (not _valid_slots or str(x).strip() in _valid_slots)]
shots.append(Shot(
shot_id=f"{shot_id_prefix}_{i:02d}",
scene_id=str(i),
duration=norm_duration(it.get("duration"), text),
type=norm_type(it.get("type")),
cast_slots=cast_slots,
character_id=anchors.character_id,
location_id=anchors.location_id,
continuity_mode="previous_frame_continuation" if chain else "fresh_location",
tech_requirements=tech_req,
visual_description=(it.get("visual_description") or it.get("description") or "").strip(),
shot_contract=ShotContract(
shot_size=(it.get("shot_size") or "").strip(),
angle=(it.get("angle") or "").strip(),
camera_move=(it.get("camera_move") or "").strip(),
lens_feel=(it.get("lens_feel") or "").strip(),
depth_of_field=(it.get("depth_of_field") or "").strip(),
frame_rate_feel=(it.get("frame_rate_feel") or "").strip(),
),
text=text or None,
sentiment=(it.get("sentiment") or "").strip() or None,
expression_detail=parse_expression(it),
action=(it.get("action") or "").strip() or None,
audio=parse_audio(it),
transition_in=parse_transition(it.get("transition_in")),
transition_out=parse_transition(it.get("transition_out")),
speed=parse_speed(it.get("speed")),
status="pending",
))
return shots
@@ -0,0 +1,304 @@
"""
directors.base — 导演系统基类(替换点①)
==========================================
任何导演系统只需实现 `generate(story, anchors, options) -> Storyboard`,
即可接入视频工厂,下游(编排/能力/执行)完全不感知用的是哪个导演。
接入新导演(3 步):
1. 在 directors/ 加一个文件,实现 BaseDirector
2. 在 directors/__init__.py 的 DIRECTORS 注册
3. (可选)填 options_schema,前端自动生成该导演的选项面板
"""
from __future__ import annotations
import sys
from pathlib import Path
from typing import Any
_SCRIPTS = Path(__file__).resolve().parent.parent
if str(_SCRIPTS) not in sys.path:
sys.path.insert(0, str(_SCRIPTS))
from schemas import GlobalAnchors, Storyboard # noqa: E402
class BaseDirector:
"""导演系统基类"""
id: str = ""
name: str = ""
kind: str = "skill" # skill(提示词包)| service(HTTP 服务)| legacy(本地既有)
description: str = ""
features: list[str] = [] # ["visual_concept", "two_pass", "consistency", "auto_pipeline"]
status: str = "ready" # ready | partial | planned
source: str = "" # 来源说明(如 ai-film-skills v5.2 / 本地既有)
options_schema: dict = {} # 该导演的专属选项(自动生成 UI 表单)
def generate(
self,
story: str,
anchors: GlobalAnchors | None = None,
options: dict | None = None,
) -> Storyboard:
"""故事 → 统一分镜 Schema(**唯一契约**)"""
raise NotImplementedError
def revise_shot(self, storyboard: Storyboard, shot_id: str,
instruction: str = "") -> Storyboard:
"""单镜头重生成(默认不支持,子类可覆写)"""
raise NotImplementedError(f"{self.name} 不支持单镜头重生成")
def info(self) -> dict:
"""给前端的能力描述"""
return {
"id": self.id, "name": self.name, "kind": self.kind,
"description": self.description, "features": self.features,
"status": self.status, "source": self.source,
"options_schema": self.options_schema,
}
# 通用选项(各导演共享的基础参数)
COMMON_OPTIONS = {
# ★ 推荐用法:给「目标总时长」,镜头数与每镜时长交给导演自行决定
"total_duration": {"type": "number", "min": 0, "max": 1800, "default": 0,
"label": "目标总时长(秒)",
"hint": "推荐用法:60 = 一分钟短片 · 180 = 三分钟 · 300 = 五分钟。"
"导演据此自由决定镜头数与每镜时长(充分解放创作)。填 0 = 不限制"},
# 以下两项**仅作参考**(导演可采纳可不采纳;填 0 = 不干涉)
"shot_count": {"type": "number", "min": 0, "max": 30, "default": 0,
"label": "镜头数(仅参考)",
"hint": "仅作参考:导演可采纳也可不采纳。填 0 = 让导演按内容自行决定(推荐)"},
"shot_duration": {"type": "number", "min": 0, "max": 15, "default": 0,
"label": "单镜时长(仅参考)",
"hint": "仅作参考:导演按内容与节奏自行决定。填 0 = 不限制"},
"shooting_style": {
"type": "enum",
"options": ["documentary_realism", "cinematic", "vlog_handheld",
"studio_clean", "selfie_pov", "interview_doc",
"anime_2d", "anime_3d", "product_ad"],
# 中文标签(前端显示,值保持英文以兼容后端)
"option_labels": {
"documentary_realism": "🎥 伪纪录片 · 真实自然(像随手拍的生活记录)",
"cinematic": "🎞️ 电影感 · 考究构图(有设计感的画面)",
"vlog_handheld": "🚶 手持 Vlog · 第一视角(边走边拍)",
"studio_clean": "💡 商业棚拍 · 高清干净(打光讲究、背景简洁)",
"selfie_pov": "📱 手机自拍 · 第一人称(举着手机自己拍自己)",
"interview_doc": "📺 访谈纪实 · 半身对镜(像接受采访)",
"anime_2d": "🎨 2D 动画 · 漫剧(手绘/动漫风格)",
"anime_3d": "🧸 3D 动画 · 卡通渲染(三维风格化)",
"product_ad": "🧊 产品广告 · 精致特写(微距/质感)",
},
"default": "documentary_realism", "label": "拍摄风格",
},
"realism_boost": {
"type": "enum",
"options": ["off", "mild", "strong"],
"option_labels": {
"off": "关闭(不做额外处理)",
"mild": "轻度(追加真实质感:噪点/毛孔/不匀光)",
"strong": "强力(↑ 并自动剔除「4K/超清/无瑕/大师作品」等反真实词)",
},
"default": "mild", "label": "反 AI 感(真实感增强)",
},
"director_style": {
"type": "enum",
"options": ["", "spielberg", "hitchcock", "kubrick", "kurosawa", "scorsese",
"fellini", "bergman", "tarkovsky", "wong_kar_wai", "nolan",
"villeneuve", "fincher", "refn", "bi_gan", "zhang_yimou",
"hou_hsiao_hsien", "park_chan_wook", "malick",
"michael_mann", "coen_brothers"],
"option_labels": {
"": "(不使用导演风格)",
"spielberg": "斯皮尔伯格 · 奇观与亲密", "hitchcock": "希区柯克 · 悬念与信息差",
"kubrick": "库布里克 · 几何与秩序", "kurosawa": "黑泽明 · 群像与天气",
"scorsese": "斯科塞斯 · 都市与旁白", "fellini": "费里尼 · 狂欢与记忆",
"bergman": "伯格曼 · 凝视与沉默", "tarkovsky": "塔可夫斯基 · 雕刻时间",
"wong_kar_wai": "王家卫 · 都市孤独", "nolan": "诺兰 · 高概念谜题",
"villeneuve": "维伦纽瓦 · 宏大克制", "fincher": "芬奇 · 外科精确",
"refn": "雷弗恩 · 霓虹停滞", "bi_gan": "毕赣 · 梦境长镜",
"zhang_yimou": "张艺谋 · 单色仪式", "hou_hsiao_hsien": "侯孝贤 · 固定远观",
"park_chan_wook": "朴赞郁 · 巴洛克反讽", "malick": "马力克 · 自然光诗意",
"michael_mann": "迈克尔·曼 · 夜色与专业", "coen_brothers": "科恩兄弟 · 冷峻荒诞",
},
"default": "", "label": "🎥 导演风格(镜头语言与叙事逻辑)",
},
"lang": {"type": "enum", "options": ["zh", "en"], "default": "zh", "label": "提示词语言"},
}
# ═══════════════════════════════════════════════════════════
# 公共工具:时长归一化(各导演共用,保证内容驱动语义一致)
# ═══════════════════════════════════════════════════════════
def shot_brief(opts: dict) -> str:
"""把「目标总时长 / 镜头数 / 单镜时长」组装成一句给导演的说明
设计原则(用户 2026-10-04 明确):
· **总时长 = 主驱动**("一分钟短片"/"五分钟短片")
· **镜头数与单镜时长仅作参考** —— 导演可采纳可不采纳
· 硬约束会削弱创作有效性 → 一律用"参考"语气,不写"必须"
让导演按题材/内容/目的自由裁镜头数,短片长篇皆可。
"""
def _int(v):
try:
return int(float(v or 0))
except Exception:
return 0
total = _int((opts or {}).get("total_duration"))
n = _int((opts or {}).get("shot_count"))
d = _int((opts or {}).get("shot_duration"))
out = ""
if total:
out = f"目标总时长:约 {total} 秒({total / 60:.1f} 分钟)。"
refs = []
if n:
refs.append(f"约 {n} 个镜头")
if d:
refs.append(f"每镜约 {d} 秒")
if refs:
out += (f"参考:{'、'.join(refs)} —— 可采纳可不采纳,"
f"请按内容与叙事节奏自行决定要分多少个镜头、每个镜头多长。")
return out or "镜头数量与每镜时长由你按内容与叙事节奏自由决定。"
def norm_duration(d, text: str = "") -> float:
"""时长归一化:内容驱动 + 台词兜底 + 上下限(2~10 秒)
· 台词兜底:中文口播约 4.5 字/秒 → 时长至少能容纳台词
· 上限 10 秒(生成模型单镜约束;超长需接续能力,由技术优选层识别)
"""
try:
v = float(d)
except Exception:
v = 5.0
if text:
need = len(str(text).strip()) / 4.5
if need > v:
v = need
return round(max(2.0, min(10.0, v)), 1)
def cast_or_character(anchors) -> str:
"""角色/演员表 → 提示词文本(**演员表优先**,含出场标注要求)
用户 2026-10-03 要求:短片常有多人物(主角/配角/人物一),
演员表(cast)比单角色字段更贴合;逐镜头出场由 cast_in_shot 标注。
"""
cc = getattr(anchors, "cast_context", "") or ""
if cc:
return (cc + '\n(多人物戏:请在每个镜头的 JSON 里用 "cast_in_shot": ["主角"] '
'标注谁出场;代号只能取自上面【】里的)')
return (getattr(anchors, "character_context", "") or getattr(anchors, "character_id", "")
or "(未指定)")
def style_pack_block(anchors) -> str:
"""拍摄风格包 → 导演可执行的「拍摄说明」块
作用:让导演在写分镜时就知道"这戏按什么风格拍"(设备/光/机位/质感/情绪),
从而产出风格一致的镜头语言,而不是每部片都写一样的通用描述。
"""
try:
from composer.realism import STYLE_PACKS
except Exception:
return ""
style = (getattr(anchors, "shooting_style", "") or "").strip()
pack = STYLE_PACKS.get(style) or {}
if not pack:
return ""
lines = [
f"【本次拍摄风格:{pack.get('label', style)}】",
f"· 设备:{pack.get('device', '')}",
f"· 光线:{pack.get('lighting', '')}",
f"· 机位与运镜:{pack.get('camera', '')}",
f"· 质感与瑕疵:{pack.get('flaws', '')}",
f"· 情绪表达:{pack.get('emotion', '')}",
"→ 每个镜头的画面描述都要符合以上设定;不要写与风格冲突的元素。",
]
boost = (getattr(anchors, "realism_boost", "") or "").strip()
if boost == "strong":
lines.append("→ 真实感要求高:避免「完美/无瑕/超清」这类修饰,多写生活化的不完美细节。")
return "\n".join(lines)
def director_style_hint(anchors) -> str:
"""导演风格 → 提示词块(空 key 返回空串)
与 style_pack_block(拍摄风格)**互补**:
· 拍摄风格 = 用什么设备/什么质感拍(自拍/手持/电影感…)
· 导演风格 = 用什么镜头语言/叙事逻辑(斯皮尔伯格/王家卫…)
"""
key = (getattr(anchors, "director_style", "") or "").strip()
if not key:
return ""
try:
from composer.director_styles import director_style_block
return director_style_block(key)
except Exception:
return ""
def realism_block() -> str:
"""「真实感纪律」提示块(注入导演提示词)
来源:anti-ai-checklist.md(外部开源研究,原文存 docs/realism/)
内容:非完美事件分配 + 2 秒节奏 + 禁止慢动作 + 抽象词黑名单
"""
try:
from composer.realism import imperfect_events_prompt
return imperfect_events_prompt()
except Exception:
return ""
# ── 拍摄风格 → 设备缺陷包映射(让导演按真实设备的物理缺陷描述画面)──
_DEVICE_MAP = {
"selfie_pov": "smartphone_vertical",
"vlog_handheld": "smartphone_vertical",
"documentary_realism": "dv_2000s",
"interview_doc": "doc_handheld",
# cinematic / studio_clean:刻意干净,不套缺陷包
}
def device_pack_hint(anchors) -> str:
"""拍摄风格 → 设备缺陷参数包(研究落地:缺陷即真实)
来源:camera-aesthetics.md(9 个设备包,原文存 docs/realism/)
"""
style = (getattr(anchors, "shooting_style", "") or "").strip()
key = _DEVICE_MAP.get(style, "")
if not key:
return ""
try:
from composer.realism import device_pack_block
return device_pack_block(key)
except Exception:
return ""
def realism_discipline() -> str:
"""去 AI 感的**提示词纪律**(供导演写分镜时遵守)
来源:anti-ai-checklist.md + atmosphere-dictionary.md(研究落地)
"""
return (
"【去 AI 感纪律(★重要,直接决定像不像真拍的)】\n"
"1. **禁用抽象氛围词**:不要写「温馨」「紧张」「孤独」这类词 —— "
"把它们翻译成**具体可拍的现象**(例:不写「温馨」,写「午后阳光透过树叶在地面"
"投下斑驳光影、杯口冒着细微热气」)。\n"
"2. **每镜头一个「非完美事件」**(全片不重复,缺陷类型也要换):\n"
" · 摄影师失误:自动对焦在主体与背景间搜索 / 构图偏离中心 / 慢了半拍才跟上\n"
" · 环境介入:微风吹动发丝 / 云影掠过导致曝光波动 / 场外有人打招呼\n"
" · 主体自然行为:注意到镜头露出真诚微笑后移开视线 / 说话被环境音打断笑场\n"
"3. **对焦要写路径**(不要笼统写「自动对焦搜索」):\n"
" ❌ 自动对焦反复搜索\n"
" ✅ 自动对焦在她脸部和她手中的杯子之间来回切换\n"
"4. **只用现场真实光源**(窗光/顶灯/手机屏幕光),禁影棚词(柔光箱/主光/轮廓光)。\n"
"5. **不要写慢动作**(除非明确设定为手机慢动作模式)——真实拍摄默认实时速度。"
)
@@ -0,0 +1,274 @@
"""
directors.legacy_scene_planner — 本地导演(现有能力的包装)
==============================================================
把「故事文本 → 分镜」用 LLM 直接完成(不依赖本地 LLM / 脚本 YAML 中转),
输出**统一分镜 Schema**。这是 v5.2 接入前的默认可⽤导演。
特点:
· 支持单镜头重生成(revise_shot)
· 输出含镜头契约(景别/机位/运动/景深)—— 用简化版专业摄影语言
· 无外部依赖,只调 DeepSeek
"""
from __future__ import annotations
import json
import logging
import sys
from pathlib import Path
from typing import Any
_SCRIPTS = Path(__file__).resolve().parent.parent
if str(_SCRIPTS) not in sys.path:
sys.path.insert(0, str(_SCRIPTS))
from schemas import GlobalAnchors, Shot, ShotContract, Storyboard # noqa: E402
from directors.base import COMMON_OPTIONS, BaseDirector, norm_duration, cast_or_character, style_pack_block, director_style_hint, shot_brief # noqa: E402
from directors.llm import DirectorLLM # noqa: E402
def _sc_val(v) -> int:
"""镜头数:0 / 空 = 自动(交给导演决定)"""
try:
return int(v or 0)
except Exception:
return 0
log = logging.getLogger("directors.legacy")
_SYSTEM = "你是专业的影视分镜师,擅长把故事拆解成可执行的镜头列表。"
_PROMPT_TMPL = """请把下面的故事拍成一支短片。
【故事】
{story}
【整体设定】
{style_pack}
{director_style}
【镜头数与时长参考】
{shot_brief}
【资产设定(★必须严格使用这些设定,不要自己另编场景/人物;同一资产在所有镜头中保持一致)】
- 角色:{character}
- 场地:{location}
- 服装:{wardrobe}
- 产品 / 道具:{props}
【要求】
1. 每个镜头要具体可拍:写清"谁在哪做什么、画面里有什么、光线如何"
2. 镜头语言要专业:景别(特写/近景/中景/全景)+ 机位角度 + 摄影机运动
3. 保持整体连贯:镜头之间是同一场景/同一人物,风格统一
4. 有台词就写台词(像真人口语),没台词留空
5. 镜头类型从这几类里选:action(动作/场景)、talking_head(对镜头说话)、background(环境空镜)
【输出格式】严格输出 JSON 数组,不要任何解释文字:
[
{{
"duration": 5,
"type": "action",
"visual_description": "画面描述(谁在哪做什么,光线/环境细节)",
"shot_size": "景别(特写/近景/中景/全景)",
"angle": "机位角度(平视/俯视/仰视/过肩)",
"camera_move": "摄影机运动(固定/推/拉/摇/跟/环绕)",
"depth_of_field": "景深(浅/中/深)",
"text": "台词或旁白(没有就空字符串)",
"sentiment": "情绪(如:平静/焦虑/欣慰)",
"action": "人物动作",
"cast_slots": ["这一镜**出场**的角色名(必须与上面【资产设定】里的角色名完全一致;
空镜/无人物镜头给空数组 [])"],
"location_id": "这一镜所在场地(必须与上面【资产设定】的场地名一致)"
}}
]
★ cast_slots 非常重要:它决定生成关键帧时**用哪几个角色的参考图做多图融合**
(特写要保脸、全景要保环境)。漏填会导致人物不一致,请如实填写。
"""
_REVISE_TMPL = """下面是一个分镜表里的第 {idx} 个镜头,请按用户要求重新生成这一个镜头。
【整片故事】
{story}
【当前镜头】
{current}
【修改要求】
{instruction}
【要求】保持与整片风格一致,只改这一个镜头。输出格式:单个 JSON 对象(不要数组),字段同上。
"""
class LegacyScenePlanner(BaseDirector):
"""本地导演(LLM 直出分镜)"""
id = "legacy_scene_planner"
name = "本地分镜(内置)"
kind = "legacy"
description = "用 LLM 把故事直接拆成专业分镜(含景别/机位/运镜),无需额外依赖"
features = ["storyboard", "revise_shot"]
status = "ready"
source = "09_ave/scripts/directors/legacy_scene_planner.py"
options_schema = dict(COMMON_OPTIONS)
def __init__(self):
self.llm = DirectorLLM()
@property
def available(self) -> bool:
return self.llm.available
# ── 生成 ─────────────────────────────────────────
def generate(self, story: str, anchors: GlobalAnchors | None = None,
options: dict | None = None) -> Storyboard:
opts = options or {}
anchors = anchors or GlobalAnchors()
shot_count = int(opts.get("shot_count") or 6)
shot_duration = float(opts.get("shot_duration") or 5)
shooting_style = opts.get("shooting_style") or "documentary_realism"
realism_boost = opts.get("realism_boost") or "mild"
director_style = opts.get("director_style") or ""
prompt = _PROMPT_TMPL.format(
shot_brief=shot_brief({"shot_count": shot_count, "shot_duration": shot_duration}),
story=(story or "").strip()[:2000],
shooting_style=shooting_style,
shot_duration=shot_duration,
# ★ 资产注入:有详情用详情,否则退回 ID
character=cast_or_character(anchors),
style_pack=style_pack_block(anchors),
director_style=director_style_hint(anchors),
location=anchors.location_context or anchors.location_id or "(未指定)",
wardrobe=anchors.wardrobe or "(未指定)",
props=anchors.props_context or "(无)",
)
raw = self.llm.chat(prompt, system=_SYSTEM, temperature=0.7, max_tokens=3000)
items = self.llm.parse_json(raw)
if isinstance(items, dict): # 容错:AI 返回 {"shots": [...]}
items = items.get("shots") or items.get("data") or []
if not isinstance(items, list) or not items:
log.warning(" ⚠️ legacy 导演未产出有效分镜")
return Storyboard(project_id=_pid(), story=story, director_id=self.id,
global_anchors=anchors, shots=[])
shots: list[Shot] = []
for i, it in enumerate(items, 1):
if not isinstance(it, dict):
continue
# ★ 出场角色(决定关键帧用哪几张角色参考图做多图融合)
# LLM 输出优先(但要过滤掉演员表里不存在的代号,防 AI 乱写);
# 没给就兜底用演员表的全部 slot(单角色场景即主角)
# 注意 anchors.cast 里是 CastMember 对象(不是 dict)——
# 照 _shot_builder 的写法用 effective_cast()
try:
_members = anchors.effective_cast() if hasattr(anchors, "effective_cast") else []
except Exception:
_members = []
_valid = {str(getattr(m, "slot", "") or "") for m in _members}
_valid.discard("")
_raw = it.get("cast_slots") or it.get("cast_in_shot") or []
if isinstance(_raw, str):
_raw = [_raw]
_cs = [str(x).strip() for x in _raw
if str(x).strip() and (not _valid or str(x).strip() in _valid)]
if not _cs:
_cs = sorted(_valid) # 兜底:演员表的全部 slot
shots.append(Shot(
shot_id=f"shot_{i:02d}",
scene_id=str(i),
duration=norm_duration(it.get("duration"), (it.get("text") or "").strip()),
type=_norm_type(it.get("type")),
cast_slots=_cs,
character_id=anchors.character_id,
location_id=(it.get("location_id") or "").strip() or anchors.location_id,
visual_description=(it.get("visual_description") or "").strip(),
shot_contract=ShotContract(
shot_size=(it.get("shot_size") or "").strip(),
angle=(it.get("angle") or "").strip(),
camera_move=(it.get("camera_move") or "").strip(),
lens_feel=(it.get("lens_feel") or "").strip(),
depth_of_field=(it.get("depth_of_field") or "").strip(),
),
text=(it.get("text") or "").strip() or None,
sentiment=(it.get("sentiment") or "").strip() or None,
action=(it.get("action") or "").strip() or None,
status="pending",
))
sb = Storyboard(
project_id=_pid(),
title=(story or "")[:40],
story=story,
content_type=opts.get("content_type") or "short_drama",
director_id=self.id,
global_anchors=anchors,
shots=shots,
)
log.info(" 🎬 legacy 导演产出 %d 个镜头(%.1f 秒)", len(shots), sb.total_duration())
return sb
# ── 单镜头重生成 ──────────────────────────────────
def revise_shot(self, sb: Storyboard, shot_id: str,
instruction: str = "") -> Storyboard:
target = sb.shot_by_id(shot_id)
if not target:
raise ValueError(f"镜头不存在: {shot_id}")
idx = sb.shots.index(target) + 1
prompt = _REVISE_TMPL.format(
idx=idx,
story=(sb.story or "")[:800],
current=json.dumps({
"duration": target.duration,
"type": target.type,
"visual_description": target.visual_description,
"shot_size": target.shot_contract.shot_size,
"camera_move": target.shot_contract.camera_move,
"text": target.text,
"sentiment": target.sentiment,
}, ensure_ascii=False, indent=1),
instruction=instruction or "让这一幕更自然、更有真实感",
)
raw = self.llm.chat(prompt, system=_SYSTEM, temperature=0.8, max_tokens=800)
it = self.llm.parse_json(raw)
if isinstance(it, list) and it:
it = it[0]
if not isinstance(it, dict):
raise RuntimeError("重生成失败:LLM 未返回有效结果")
target.duration = float(it.get("duration") or target.duration)
target.type = _norm_type(it.get("type") or target.type)
target.visual_description = (it.get("visual_description") or target.visual_description).strip()
target.shot_contract = ShotContract(
shot_size=(it.get("shot_size") or "").strip(),
angle=(it.get("angle") or "").strip(),
camera_move=(it.get("camera_move") or "").strip(),
lens_feel=(it.get("lens_feel") or "").strip(),
depth_of_field=(it.get("depth_of_field") or "").strip(),
)
target.text = (it.get("text") or "").strip() or None
target.sentiment = (it.get("sentiment") or "").strip() or None
target.action = (it.get("action") or "").strip() or None
log.info(" 🔄 镜头重生成: %s", shot_id)
return sb
# ── 工具 ──────────────────────────────────────────────
def _norm_type(t: Any) -> str:
t = (t or "").strip().lower()
aliases = {
"action": "action", "talking_head": "talking_head", "talk": "talking_head",
"background": "background", "b_roll": "background", "vfx": "vfx",
"comfyui": "comfyui", "3d_character": "3d_character",
}
return aliases.get(t, "action")
def _pid() -> str:
from datetime import datetime
return "vid_" + datetime.now().strftime("%Y%m%d_%H%M%S")
@@ -0,0 +1,141 @@
"""
directors.llm — 导演层共用的轻量 LLM 客户端
=============================================
配置优先级:环境变量 DEEPSEEK_* > agent-local local.yaml(llm) > 09_ave/config.yaml(llm)
(与评论生成 AIGenerator 一致的取配置方式,但独立实现,避免跨模块耦合)
仅提供最简 chat 调用 + JSON 解析,供各导演实现使用。
"""
from __future__ import annotations
import json
import logging
import os
import re
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any
import yaml
from lib.http import urlopen as _noproxy_urlopen # 禁代理+重试
log = logging.getLogger("directors.llm")
_SCRIPTS = Path(__file__).resolve().parent.parent # 09_ave/scripts/
_AVE_DIR = _SCRIPTS.parent # 09_ave/
_AGENT_LOCAL = Path(os.environ.get("AGENT_LOCAL") or (_AVE_DIR.parents[2] / "agent-local"))
_LOCAL_CFG = _AGENT_LOCAL / "tools" / "ave" / "config" / "local.yaml"
_AVE_CFG = _AVE_DIR / "config.yaml"
def load_llm_config() -> dict:
"""三级优先级读取 LLM 配置"""
cfg: dict = {}
# ③ 09_ave/config.yaml
if _AVE_CFG.exists():
try:
raw = yaml.safe_load(_AVE_CFG.read_text(encoding="utf-8")) or {}
cfg.update(raw.get("llm") or {})
except Exception:
pass
# ② 配置中心 local.yaml → llm
if _LOCAL_CFG.exists():
try:
raw = yaml.safe_load(_LOCAL_CFG.read_text(encoding="utf-8")) or {}
cfg.update(raw.get("llm") or {})
except Exception:
pass
# ① 环境变量
if os.environ.get("DEEPSEEK_API_KEY"):
cfg["api_key"] = os.environ["DEEPSEEK_API_KEY"]
if os.environ.get("DEEPSEEK_BASE_URL"):
cfg["base_url"] = os.environ["DEEPSEEK_BASE_URL"]
if os.environ.get("DEEPSEEK_MODEL"):
cfg["model"] = os.environ["DEEPSEEK_MODEL"]
cfg.setdefault("base_url", "https://api.deepseek.com/v1")
cfg.setdefault("model", "deepseek-v4-flash")
return cfg
class DirectorLLM:
"""导演层 LLM 客户端(单次同步调用)"""
def __init__(self, timeout: int = 90):
cfg = load_llm_config()
self.api_key = (cfg.get("api_key") or "").strip()
self.base_url = (cfg.get("base_url") or "https://api.deepseek.com/v1").rstrip("/")
self.model = cfg.get("model") or "deepseek-v4-flash"
self.timeout = timeout
self._fails = 0
@property
def available(self) -> bool:
return bool(self.api_key) and self._fails < 3
def chat(self, prompt: str, system: str = "", temperature: float = 0.7,
max_tokens: int = 3000) -> str:
"""单次对话(失败返回空串,连续 3 次失败熔断)"""
if not self.available:
log.warning(" ⚠️ LLM 不可用(未配 key 或已熔断)")
return ""
messages = []
if system:
messages.append({"role": "system", "content": system})
messages.append({"role": "user", "content": prompt})
body = json.dumps({
"model": self.model,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
# 关思考模式:分镜生成不需要长推理,省 token 且更快
"thinking": {"type": "disabled"},
}).encode("utf-8")
req = urllib.request.Request(
f"{self.base_url}/chat/completions", data=body,
headers={"Content-Type": "application/json",
"Authorization": f"Bearer {self.api_key}"})
try:
with _noproxy_urlopen(req, timeout=self.timeout) as r:
data = json.loads(r.read().decode("utf-8"))
content = (data.get("choices") or [{}])[0].get("message", {}).get("content", "")
self._fails = 0
return (content or "").strip()
except urllib.error.HTTPError as e:
detail = e.read().decode("utf-8", errors="ignore")[:200]
log.error(" ❌ LLM HTTP %s: %s", e.code, detail)
self._fails += 1
return ""
except Exception as e:
log.error(" ❌ LLM 调用失败: %s", e)
self._fails += 1
return ""
# ── JSON 解析(容错)──────────────────────────────
@staticmethod
def parse_json(text: str) -> Any:
"""从 LLM 输出里提取 JSON(容忍 markdown 围栏 / 前后废话)"""
if not text:
return None
t = text.strip()
# 去 markdown 围栏
m = re.search(r"```(?:json)?\s*(.+?)\s*```", t, re.S)
if m:
t = m.group(1).strip()
# 直接解析
try:
return json.loads(t)
except Exception:
pass
# 找第一个 { 或 [ 到最后一个 } 或 ]
for open_c, close_c in (("{", "}"), ("[", "]")):
i, j = t.find(open_c), t.rfind(close_c)
if i >= 0 and j > i:
try:
return json.loads(t[i:j + 1])
except Exception:
continue
log.warning(" ⚠️ LLM 输出无法解析为 JSON(前 120 字: %s)", t[:120])
return None
@@ -0,0 +1,385 @@
"""
directors.seedance_director — Seedance 导演方法论(skill 型导演)
来源:Seedance-Director-4.0(SCAI)导演方法论,去人设/去平台命令后精编移植,
见 09_ave/skills/seedance-director/SKILL.md
特点(与 v5.2 的差异):
· 强调「分轨 / 场一个职 / 条一个目的 / 镜头必须干活」
· 时长跟内容与气口走(容量不是目标;超限则切条并声明接续需求)
· 产出后自检「五类病」(矛盾/断链/走样/模型雷/红线)
· 输出同一套 Shot Schema(复用 _shot_builder),下游无感知
"""
from __future__ import annotations
import logging
import sys
from pathlib import Path
from typing import Any
_SCRIPTS = Path(__file__).resolve().parent.parent
if str(_SCRIPTS) not in sys.path:
sys.path.insert(0, str(_SCRIPTS))
from schemas import GlobalAnchors, Storyboard # noqa: E402
from directors.base import COMMON_OPTIONS, BaseDirector, cast_or_character, style_pack_block, realism_block, director_style_hint, device_pack_hint, realism_discipline, shot_brief # noqa: E402
from directors._shot_builder import build_shots_from_items, norm_type # noqa: E402
from directors.llm import DirectorLLM # noqa: E402
def _sc_val(v) -> int:
"""镜头数:0 / 空 = 自动(交给导演按叙事需要决定)"""
try:
return int(v or 0)
except Exception:
return 0
log = logging.getLogger("directors.seedance")
_AVE_DIR = _SCRIPTS.parent
SKILL_PATHS = [
_AVE_DIR / "skills" / "seedance-director" / "SKILL.md",
]
_SKILL_MAX_CHARS = 13000
_PROMPT_TMPL = """请按「Seedance 导演方法论」把下面的故事拍成一支短片。
{shot_brief}
【故事】
{story}
【整体设定】
- 拍摄风格:{shooting_style}
- {shot_brief}
【资产设定(★严格使用,不要另编;同一资产在所有镜头保持一致)】
- 角色:{character}
- 场地:{location}
- 服装:{wardrobe}
- 产品/道具:{props}
【场景真实感纪律(★重要,直接影响"像不像真拍的")】
**禁止影棚语言**——生活场景里不该出现专业影棚的词汇,写了画面立刻变假:
- ❌ 禁止:柔光箱 / 布光("柔光箱均匀布光""侧面打光")/ 纯色背景 / 纯灰背景 /
中焦 85mm / 压缩空间 / 影棚 / 摄影棚 / 反光板 / 灯架
- ✅ 只用**场景里真实存在的光源**,并说清它从哪来:
窗户自然光 / 室内顶灯 / 床头灯 / 台灯 / 手机屏幕的光 / 走廊灯 / 电视光
- ❌ 禁止把背景写成"纯色/虚化背景"——真实房间的背景就是**杂乱的真实物件**
(输液架、床头柜、水杯、被子褶皱、墙上的挂钩)
【拍摄者的动作逻辑(★自拍/Vlog 必检)】
画面里"手机是谁拿的"必须说得通,否则一眼假:
- **人物自己举着**:那他的**双手被占用**,不能再做"抓床单/换药/双手撑床"这类动作
- **他人帮拍**(护士/家属/路人):必须写明"他人在拍",并允许这些真实特征:
画面晃动 / 对焦慢半拍 / 构图歪 / 短暂拍到地面或天花板 / 拍摄者在画面外说话
- **手机放置**(立在床头柜、靠着水杯、半躺在床上):允许角度歪、中途滑落调整、
只拍到半个身子
- ⚠️ **手被占住的场景**(换药、剧痛抓床单、双手抱头)**不能**写成"自己举着手机自拍"
【慌乱/失控场景的真实特征(★疼、急、乱的时候必带)】
真实的手持拍摄在慌乱中一定会露出这些"瑕疵",它们才是真实感的来源:
- 镜头晃动加剧、跟不住主体
- 焦点来回漂移、短暂失焦
- 构图来不及调整(人物时而偏出画面)
- 呼吸声/喘气声进麦克风
- 镜头短暂甩到别处(墙、天花板、被子)再拉回来
【镜头时长纪律(★硬约束)】
生成模型单镜头**最长 10 秒**(可灵只有 5 秒 / 10 秒两档),这是硬限制。因此:
- 单镜时长写 **2~10 秒**(整数或 .5,按内容与气口定,不要凑数)
- 超过 10 秒的连续动作**必须主动拆成多个镜头**,拆法:
· **按动作阶段**:起势 → 发力 → 收势(如"慢慢坐下"→"落座瞬间的表情"→"坐定后的长舒一口气")
· **按景别递进**:中景交代 → 特写强调 → 反应镜头(观众看不出断点)
· **按台词气口**:一句话说完整,下一句换镜头
- ❌ 不要写「一个 15 秒的长镜头」「持续 20 秒的连续动作」—— 生成不出来
- ✅ 拆分后在每个镜头里写清它负责哪个阶段,让观众自然接上
【镜头描述纪律(★ 来自专业导演方法论,直接决定生成质量)】
**① 禁止「形容词堆砌」** —— 这是外行与专业的分水岭:
❌ 「电影感、戏剧性、大师作品、8K、唯美、氛围感」
这些词不告诉模型:机位在哪、身体做什么、动作在哪结束、什么不能变
✅ 用**可观察的物理描述**替代:机位 / 景别 / 身体具体动作 / 环境反应 / 收尾状态
**② 一个镜头 = 一个主导运动**(或刻意静止)
❌ 「镜头旋转、变焦、平移、跟拍、晃动」
✅ 「固定机位,极缓慢地向前推;除此之外没有其他运动」
**③ 每个动作必须有「起止状态」**(最重要、我们过去最缺):
✅ 「他单膝跪地开始 → 缓缓抬头到湿发滑落露出眼睛 → 结束时半起身、重心在前膝、头抬起」
(只说"他挣扎着"没有任何起止 = 模型自由发挥 = 不可控)
**④ 加**一个**环境反应**(让画面"活"起来):
✅ 「雨点不断打破水洼表面;远处闪电短暂照亮街道尽头」
**⑤ 不变量逐字重复**(跨镜头锁定):
同一场戏里,人物的脸 / 发型 / 服装 / 场地 / 光线,在每个镜头提示词里
**用同样的词原样重复** —— 不要换说法("齐肩直发" 每次都必须写"齐肩直发")
**⑥ 负面词只列本镜头**真实风险**,且必须是**具体实例**:
❌ 「不要现代物品」(模型无法解析"类别")
✅ 「不要手表、不要印刷 logo、不要橡胶鞋底」
**⑦ 竖屏(9:16)不是横屏裁切**:
· 用**纵深**调度(前后景分层),而不是横向铺开
· **主体放大**(竖屏里人物占比要更大)
· **每个全景镜头都要重新设计**(横屏的构图逻辑在竖屏里不成立)
**⑧ 画面必须提供「配音之外的信息」**:
如果画面只是把台词演一遍 = 观众没有看的理由。画面要承载:状态变化 / 关系 / 潜台词。
【台词口语化纪律(★重要,直接决定"像不像真人说话")】
真实的人开口**不是写作文**。台词必须像真人说话,而不是念稿:
❌ 书面:「我住院第三天了,真没想象中那么可怕。」
✅ 口语:「住院第三天了……说实话,没想象中那么可怕。」
**七条要求**:
1. **口语词**:用「嗯、啊、就、那个、说实话、其实、反正、怎么样」这类真实语气词
2. **停顿与迟疑**:用「……」表示停顿,用「(停顿)」「(轻声)」标注语气
3. **允许不完美**:可以重复(「我、我是说」)、改口、没说完(被打断)
4. **短句为主**:一句尽量 ≤15 字;长句要拆开,像真人一口气说不完
5. **禁书面词**:不写「因此 / 然而 / 致使 / 综上所述 / 未免」
6. **贴合人物**:老人、孩子、医生、病人的说话方式不同(用词、语速感要区分)
7. **一句一个意思**:一句话不做两件事(先说完这个,再接着说下个)
**额外**:如果这个镜头里有人物开口,必须在画面描述里同时写**说话时的身体状态**
(呼吸急促 / 嘴角抽动 / 眼神游移 / 手不自觉地攥紧),台词和身体不能分开设计。
【画面描述的纪律(★重要,直接影响生成质量)】
"visual_description" 只写**镜头里能看到什么**,严禁混入制作意图:
❌ 禁止:「方便出结尾字幕」「保持2秒让字幕显示」「便于后期剪辑」「留白给标题」「方便加标签」
✅ 只写:人物动作 / 表情 / 镜头运动 / 环境细节
(字幕、时长、后期、剪辑都属于制作层,不是画面内容 —— 写进提示词会污染生成)
【非语言发声必须标 sfx(★重要)】
人物发出的**非语言声音**要写进 sfx,**不要塞进台词 text**:
· 惨叫 / 闷哼 / 痛呼 / 倒吸一口气 / 叹气 / 干笑 / 清嗓 / 吞咽 / 哽咽
写法:{{"at_second": 1.5, "desc": "痛苦的闷哼"}}
(text 只放"说出来的话";发声类走 sfx —— 后期能配真实音效,比 TTS 念字自然得多)
例:换药镜头 → text 可以是空,sfx 写 [{{"at_second": 0.8, "desc": "倒吸一口气"}}, {{"at_second": 2.0, "desc": "痛苦的闷哼"}}]
【空间与走位纪律(★重要,直接决定画面是否"看得懂")】
每个镜头必须说清「**人在空间的哪儿、朝哪、怎么动**」——三者缺一,观众就看不懂空间关系:
- **位置**:人物处在场景的哪个锚点(如:病床右侧 / 窗前 / 门口 / 走廊中段)
- **朝向**:面朝哪个方向(如:面向窗户 / 背对镜头 / 侧向病床 / 半侧身朝门口)
- **移动**:这一步是静止不动 / 小幅位移 / 转身;**并写清方位变化**
(如:从床边向窗户走两步 / 缓缓转身背对镜头)
对照写法:
❌ 「他在病房里说话」(观众不知道人和空间的关系 → 换机位就乱)
✅ 「他坐在病床右侧,面朝窗户,说到一半慢慢站起来转向门口」
⚠️ **单镜头内不要写长距离移动**(走 5 米这种,模型画不出来,会穿模/瞬移):
· 3 步以内的小幅位移 → 可以(如起身、上前一步、转身)
· 超过 3 步 / 跨越整个空间 → **必须拆成多个镜头**:
镜1 在床边起身(中景)→ 镜2 走向窗户(全景,能看出距离)→ 镜3 站在窗前(近景)
且**每个镜头用对应机位的场景参考图**,空间才连贯。
【拍摄风格与镜头视角纪律(★重要)】
拍摄风格决定"谁在看",镜头语言必须与之一致,不要写错视角:
- 📱 **手机自拍·第一人称(selfie_pov)**:镜头 = 人物自己举着的手机。
· 画面里是"他自己"(近脸、一臂距离、轻微广角畸变)
· 允许"看镜头说话"(对镜自述)
· ❌ 不要写第三人称观察视角(如"镜头从侧面拍他")
· ⚠️ **全程统一**:包括开场/过场镜,也只能是"人物自己的视角"
(如:举着手机照路 / 低头看自己脚下的鞋 / 手机屏幕反射中的自己),
不要出现"从身后跟拍脚步"这类旁人视角
- 🚶 **手持 Vlog·第一视角(vlog_handheld)**:镜头 = 拍摄者的眼睛。
· 画面里是"他看到的"(前方、手中物、同行的人)
· 拍摄者本人通常不入画,或只有手/手臂入画
- 📺 **访谈纪实(interview_doc)**:机位固定,人物半身对镜说话,可有停顿与思考
- 🎬 **电影感 / 伪纪录片**:第三人称观察视角,可用正反打 / 过肩镜头
{style_pack}
{device_pack}
{realism_discipline}
{realism}
{director_style}
【多角色一致性纪律(★重要,直接影响成片质量)】
- **同框限制**:图生视频只能喂「一张」角色参考图 → 多角色同框的近景/特写会让次要角色人脸随机漂移。
因此:
· 避免「两个以上角色同框且都要清晰露脸(近景/特写)」
· 双人戏优先用:**正反打**(单人镜头交替)· **过肩镜头**(一方背对或只露侧影)·
**中远景**(脸小、容错高)· **背影 / 手部 / 局部**
· 确需同框时:让"主角"占据画面视觉中心,次要角色可侧身、偏离焦平面或虚化处理
- **出场标注**:每个镜头用 `cast_in_shot` 精确标注**实际出场**的人物代号(没出场不要写)。
单人镜头只写一个人 —— 这是生成时绑定正确参考图(人脸一致)的前提。
【拆条纪律】
1. 先分轨(叙事轨要转 / 表演轨要成立 / 展示轨要让观众看懂一件事),再拆条。
2. **条一个目的**:一次生成一件事。一条里要用「并且」才说得全 = 该切条。
3. **镜头必须干活**(改情绪/推进动作/加压),否则删;运镜必须回答「什么变了」。
4. 时长按内容与气口:台词按中文字数(约 4.5 字/秒)估算,情绪停顿/呼吸/凝视要留时间;
单镜 2~10 秒;超过 10 秒的内容 → 切成两条,前一条 transition_out 与后一条 chain_from_prev 呼应。
【输出格式】严格输出 JSON 数组,不要任何解释:
[
{{
"duration": 4,
"type": "action | talking_head | background | vfx",
"cast_in_shot": ["主角"],
"visual_description": "这一条观众看见什么(具体到可拍)",
"shot_size": "景别", "angle": "机位角度", "camera_move": "运动(须回答什么变了)",
"depth_of_field": "浅/中/深", "lens_feel": "光学质感", "frame_rate_feel": "常规/升格/降格",
"text": "台词(逐字保留,不改写不润色;无则空)",
"sentiment": "情绪",
"expression_detail": {{"eyes": "眼神", "mouth": "嘴部", "brow": "眉宇"}},
"action": "可见行为(不写情绪形容词)",
"audio_cue": "环境音(有因果)",
"sfx": [{{"at_second": 1.2, "desc": "音效"}}],
"transition_in": {{"type": "cut", "duration": 0}},
"transition_out": {{"type": "cut", "duration": 0}},
"speed": {{"factor": 1.0, "ramp": ""}},
"tech_requirements": {{
"continuity": {{"character": "must", "wardrobe": "must", "location": "must", "expression": "prefer", "props": "none"}},
"chain_from_prev": false,
"motion_level": "small",
"camera_difficulty": "simple",
"needs": []
}}
}}
]
产出前自检(五类病):矛盾 / 断链 / 走样 / 模型雷 / 红线。
前四类自己改;模型雷与红线写进对应镜头的 tech_requirements.needs,不要硬写做不到的画面。
"""
_REVISE_TMPL = """按 Seedance 导演方法论重生成这一个镜头(保持整片统一,镜头必须干活)。
【整片故事】{story}
【当前镜头(第 {idx} 个)】{current}
【修改要求】{instruction}
【要求】只改这一个镜头,输出单个 JSON 对象(字段同上)。
"""
def skill_available() -> tuple[bool, str]:
for p in SKILL_PATHS:
if p.exists() and p.stat().st_size > 500:
return True, str(p)
return False, "技能文档未就位(需 09_ave/skills/seedance-director/SKILL.md)"
def load_skill_doc() -> str:
ok, path = skill_available()
if not ok:
return ""
try:
return Path(path).read_text(encoding="utf-8")[:_SKILL_MAX_CHARS]
except Exception as e:
log.warning(" ⚠️ Seedance 技能文档读取失败: %s", e)
return ""
class SeedanceDirector(BaseDirector):
"""Seedance 导演方法论(分轨 / 条一个目的 / 五类病自检)"""
id = "seedance_director"
name = "Seedance 导演方法论"
kind = "skill"
description = "Seedance 方法论移植:分轨拆条、时长跟内容与气口走、镜头必须干活、五类病自检"
features = ["storyboard", "track_split", "shot_contract", "self_check", "tech_requirements"]
status = "ready"
source = "Seedance-Director-4.0(SCAI)· 精编移植(去人设)"
options_schema = dict(COMMON_OPTIONS)
def __init__(self):
self.llm = DirectorLLM(timeout=120)
@property
def available(self) -> bool:
ok, _ = skill_available()
return ok and self.llm.available
def generate(self, story: str, anchors: GlobalAnchors | None = None,
options: dict | None = None) -> Storyboard:
opts = options or {}
anchors = anchors or GlobalAnchors()
skill = load_skill_doc()
if not skill:
raise RuntimeError("Seedance 技能文档未就位,无法生成")
prompt = _PROMPT_TMPL.format(
shot_brief=shot_brief(opts),
story=(story or "").strip()[:2000],
shooting_style=opts.get("shooting_style") or "documentary_realism",
character=cast_or_character(anchors),
style_pack=style_pack_block(anchors),
device_pack=device_pack_hint(anchors),
realism_discipline=realism_discipline(),
realism=realism_block(),
director_style=director_style_hint(anchors),
location=anchors.location_context or anchors.location_id or "(未指定)",
wardrobe=anchors.wardrobe or "(未指定)",
props=anchors.props_context or "(无)",
)
raw = self.llm.chat(prompt, system=skill, temperature=0.7, max_tokens=3500)
items = self.llm.parse_json(raw)
if isinstance(items, dict):
items = items.get("shots") or items.get("data") or items.get("storyboard") or []
if not isinstance(items, list) or not items:
log.warning(" ⚠️ Seedance 导演未产出有效分镜")
return Storyboard(project_id=_pid(), story=story, director_id=self.id,
global_anchors=anchors, shots=[])
shots = build_shots_from_items(items, anchors)
durations = [s.duration for s in shots]
if len(set(durations)) <= 1 and len(shots) > 1:
log.warning(" ⚠️ Seedance 产出镜头时长全部相同(%ss) — 未按气口差异化", durations[:1])
sb = Storyboard(
project_id=_pid(), title=(story or "")[:40], story=story,
content_type=opts.get("content_type") or "short_drama",
director_id=self.id, global_anchors=anchors, shots=shots,
)
log.info(" 🎬 Seedance 导演产出 %d 个镜头(%.1f 秒)", len(shots), sb.total_duration())
return sb
def revise_shot(self, sb: Storyboard, shot_id: str,
instruction: str = "") -> Storyboard:
target = sb.shot_by_id(shot_id)
if not target:
raise ValueError(f"镜头不存在: {shot_id}")
skill = load_skill_doc()
import json as _json
prompt = _REVISE_TMPL.format(
story=(sb.story or "")[:800],
idx=sb.shots.index(target) + 1,
current=_json.dumps({
"visual_description": target.visual_description,
"shot_size": target.shot_contract.shot_size,
"camera_move": target.shot_contract.camera_move,
"text": target.text, "sentiment": target.sentiment,
"tech_requirements": target.tech_requirements,
}, ensure_ascii=False, indent=1),
instruction=instruction or "更有导演感,镜头必须干活",
)
raw = self.llm.chat(prompt, system=skill[:2500] if skill else "", temperature=0.8, max_tokens=900)
it = self.llm.parse_json(raw)
if isinstance(it, list) and it:
it = it[0]
if not isinstance(it, dict):
raise RuntimeError("重生成失败:LLM 未返回有效结果")
# 复用公共构建器解析单条
one = build_shots_from_items([it], sb.global_anchors)
if one:
n = one[0]
target.duration = n.duration
target.type = n.type
target.visual_description = n.visual_description
target.shot_contract = n.shot_contract
target.text = n.text
target.sentiment = n.sentiment
target.expression_detail = n.expression_detail
target.action = n.action
target.audio = n.audio
target.tech_requirements = n.tech_requirements
return sb
def _pid() -> str:
from datetime import datetime
return "vid_" + datetime.now().strftime("%Y%m%d_%H%M%S")
@@ -0,0 +1,424 @@
"""
directors.storyboard_v52 — AI Storyboard Director v5.2(技能型导演)
=====================================================================
来源:ai-film-skills(Apache-2.0)
`skills/ai-film-skills/docs/skills/zh-CN/ai-storyboard-director.md`
v5.2 的核心特点(相对本地导演):
① **先锁视觉概念,再拆镜头**(两遍交付:设计稿 → 生产稿)
② 镜头契约更专业:景别 / 机位 / 光学 / 运动 / 声音 / 切点
③ 强调"可执行":每个镜头能直接交给生成模型
实现方式:把技能文档作为**系统提示词**注入,让 LLM 按 v5.2 方法论产出分镜,
再转换为统一分镜 Schema(下游无感知)。
技能文档缺失时本导演自动标记为「不可用」,前端下拉会置灰。
"""
from __future__ import annotations
import logging
import sys
from pathlib import Path
from typing import Any
_SCRIPTS = Path(__file__).resolve().parent.parent
if str(_SCRIPTS) not in sys.path:
sys.path.insert(0, str(_SCRIPTS))
from schemas import (GlobalAnchors, Shot, ShotContract, Storyboard, # noqa: E402
Transition, Speed, AudioConfig, SFX, ExpressionDetail)
from directors.base import COMMON_OPTIONS, BaseDirector, norm_duration, cast_or_character, style_pack_block, realism_block, director_style_hint, device_pack_hint, realism_discipline, shot_brief # noqa: E402
from directors._shot_builder import (build_shots_from_items, # noqa: E402
norm_type as _norm_type)
from directors.llm import DirectorLLM # noqa: E402
def _sc_val(v) -> int:
"""镜头数:0 / 空 = 自动(交给导演按叙事需要决定)"""
try:
return int(v or 0)
except Exception:
return 0
log = logging.getLogger("directors.v52")
# 技能文档位置(ai-film-skills 下载到 09_ave/skills/)
_AVE_DIR = _SCRIPTS.parent
SKILL_PATHS = [
_AVE_DIR / "skills" / "ai-film-skills" / "docs" / "skills" / "zh-CN" / "ai-storyboard-director.md",
_AVE_DIR / "skills" / "ai-film-skills" / "docs" / "skills" / "ai-storyboard-director.md",
]
# 技能文档注入上限(避免 prompt 过长)
_SKILL_MAX_CHARS = 6000
_PROMPT_TMPL = """请按「导演级分镜」标准,把下面的故事拍成一支短片。
{shot_brief}
【故事】
{story}
【整体设定】
- 拍摄风格:{shooting_style}
- {shot_brief}
★【时长规则(必须遵守,按内容算)】
· 短对白(≤12字) / 快切反应 / 插入细节 → 2~3 秒
· 中长对白(13~30字) → 4~6 秒(按台词字数估算:中文约 4~5 字/秒)
· 动作 / 走位调度 → 3~5 秒
· 情绪停顿 / 凝视 / 环境空镜 → 3~5 秒
· 需要连续表演的长镜头 → 6~10 秒(同时把 tech_requirements.chain_from_prev 设为 true)
· 硬约束:时长必须能容纳台词(台词字数 ÷ 4.5 = 最少秒数);单镜不超过 10 秒
【资产设定(★必须严格使用这些设定,不要自己另编场景/人物;同一资产在所有镜头中保持一致)】
- 角色:{character}
- 场地:{location}
- 服装:{wardrobe}
- 产品 / 道具:{props}
【场景真实感纪律(★重要,直接影响"像不像真拍的")】
**禁止影棚语言**——生活场景里不该出现专业影棚的词汇,写了画面立刻变假:
- ❌ 禁止:柔光箱 / 布光("柔光箱均匀布光""侧面打光")/ 纯色背景 / 纯灰背景 /
中焦 85mm / 压缩空间 / 影棚 / 摄影棚 / 反光板 / 灯架
- ✅ 只用**场景里真实存在的光源**,并说清它从哪来:
窗户自然光 / 室内顶灯 / 床头灯 / 台灯 / 手机屏幕的光 / 走廊灯 / 电视光
- ❌ 禁止把背景写成"纯色/虚化背景"——真实房间的背景就是**杂乱的真实物件**
(输液架、床头柜、水杯、被子褶皱、墙上的挂钩)
【拍摄者的动作逻辑(★自拍/Vlog 必检)】
画面里"手机是谁拿的"必须说得通,否则一眼假:
- **人物自己举着**:那他的**双手被占用**,不能再做"抓床单/换药/双手撑床"这类动作
- **他人帮拍**(护士/家属/路人):必须写明"他人在拍",并允许这些真实特征:
画面晃动 / 对焦慢半拍 / 构图歪 / 短暂拍到地面或天花板 / 拍摄者在画面外说话
- **手机放置**(立在床头柜、靠着水杯、半躺在床上):允许角度歪、中途滑落调整、
只拍到半个身子
- ⚠️ **手被占住的场景**(换药、剧痛抓床单、双手抱头)**不能**写成"自己举着手机自拍"
【慌乱/失控场景的真实特征(★疼、急、乱的时候必带)】
真实的手持拍摄在慌乱中一定会露出这些"瑕疵",它们才是真实感的来源:
- 镜头晃动加剧、跟不住主体
- 焦点来回漂移、短暂失焦
- 构图来不及调整(人物时而偏出画面)
- 呼吸声/喘气声进麦克风
- 镜头短暂甩到别处(墙、天花板、被子)再拉回来
【镜头时长纪律(★硬约束)】
生成模型单镜头**最长 10 秒**(可灵只有 5 秒 / 10 秒两档),这是硬限制。因此:
- 单镜时长写 **2~10 秒**(整数或 .5,按内容与气口定,不要凑数)
- 超过 10 秒的连续动作**必须主动拆成多个镜头**,拆法:
· **按动作阶段**:起势 → 发力 → 收势(如"慢慢坐下"→"落座瞬间的表情"→"坐定后的长舒一口气")
· **按景别递进**:中景交代 → 特写强调 → 反应镜头(观众看不出断点)
· **按台词气口**:一句话说完整,下一句换镜头
- ❌ 不要写「一个 15 秒的长镜头」「持续 20 秒的连续动作」—— 生成不出来
- ✅ 拆分后在每个镜头里写清它负责哪个阶段,让观众自然接上
【镜头描述纪律(★ 来自专业导演方法论,直接决定生成质量)】
**① 禁止「形容词堆砌」** —— 这是外行与专业的分水岭:
❌ 「电影感、戏剧性、大师作品、8K、唯美、氛围感」
这些词不告诉模型:机位在哪、身体做什么、动作在哪结束、什么不能变
✅ 用**可观察的物理描述**替代:机位 / 景别 / 身体具体动作 / 环境反应 / 收尾状态
**② 一个镜头 = 一个主导运动**(或刻意静止)
❌ 「镜头旋转、变焦、平移、跟拍、晃动」
✅ 「固定机位,极缓慢地向前推;除此之外没有其他运动」
**③ 每个动作必须有「起止状态」**(最重要、我们过去最缺):
✅ 「他单膝跪地开始 → 缓缓抬头到湿发滑落露出眼睛 → 结束时半起身、重心在前膝、头抬起」
(只说"他挣扎着"没有任何起止 = 模型自由发挥 = 不可控)
**④ 加**一个**环境反应**(让画面"活"起来):
✅ 「雨点不断打破水洼表面;远处闪电短暂照亮街道尽头」
**⑤ 不变量逐字重复**(跨镜头锁定):
同一场戏里,人物的脸 / 发型 / 服装 / 场地 / 光线,在每个镜头提示词里
**用同样的词原样重复** —— 不要换说法("齐肩直发" 每次都必须写"齐肩直发")
**⑥ 负面词只列本镜头**真实风险**,且必须是**具体实例**:
❌ 「不要现代物品」(模型无法解析"类别")
✅ 「不要手表、不要印刷 logo、不要橡胶鞋底」
**⑦ 竖屏(9:16)不是横屏裁切**:
· 用**纵深**调度(前后景分层),而不是横向铺开
· **主体放大**(竖屏里人物占比要更大)
· **每个全景镜头都要重新设计**(横屏的构图逻辑在竖屏里不成立)
**⑧ 画面必须提供「配音之外的信息」**:
如果画面只是把台词演一遍 = 观众没有看的理由。画面要承载:状态变化 / 关系 / 潜台词。
【台词口语化纪律(★重要,直接决定"像不像真人说话")】
真实的人开口**不是写作文**。台词必须像真人说话,而不是念稿:
❌ 书面:「我住院第三天了,真没想象中那么可怕。」
✅ 口语:「住院第三天了……说实话,没想象中那么可怕。」
**七条要求**:
1. **口语词**:用「嗯、啊、就、那个、说实话、其实、反正、怎么样」这类真实语气词
2. **停顿与迟疑**:用「……」表示停顿,用「(停顿)」「(轻声)」标注语气
3. **允许不完美**:可以重复(「我、我是说」)、改口、没说完(被打断)
4. **短句为主**:一句尽量 ≤15 字;长句要拆开,像真人一口气说不完
5. **禁书面词**:不写「因此 / 然而 / 致使 / 综上所述 / 未免」
6. **贴合人物**:老人、孩子、医生、病人的说话方式不同(用词、语速感要区分)
7. **一句一个意思**:一句话不做两件事(先说完这个,再接着说下个)
**额外**:如果这个镜头里有人物开口,必须在画面描述里同时写**说话时的身体状态**
(呼吸急促 / 嘴角抽动 / 眼神游移 / 手不自觉地攥紧),台词和身体不能分开设计。
【画面描述的纪律(★重要,直接影响生成质量)】
"visual_description" 只写**镜头里能看到什么**,严禁混入制作意图:
❌ 禁止:「方便出结尾字幕」「保持2秒让字幕显示」「便于后期剪辑」「留白给标题」「方便加标签」
✅ 只写:人物动作 / 表情 / 镜头运动 / 环境细节
(字幕、时长、后期、剪辑都属于制作层,不是画面内容 —— 写进提示词会污染生成)
【非语言发声必须标 sfx(★重要)】
人物发出的**非语言声音**要写进 sfx,**不要塞进台词 text**:
· 惨叫 / 闷哼 / 痛呼 / 倒吸一口气 / 叹气 / 干笑 / 清嗓 / 吞咽 / 哽咽
写法:{{"at_second": 1.5, "desc": "痛苦的闷哼"}}
(text 只放"说出来的话";发声类走 sfx —— 后期能配真实音效,比 TTS 念字自然得多)
例:换药镜头 → text 可以是空,sfx 写 [{{"at_second": 0.8, "desc": "倒吸一口气"}}, {{"at_second": 2.0, "desc": "痛苦的闷哼"}}]
【空间与走位纪律(★重要,直接决定画面是否"看得懂")】
每个镜头必须说清「**人在空间的哪儿、朝哪、怎么动**」——三者缺一,观众就看不懂空间关系:
- **位置**:人物处在场景的哪个锚点(如:病床右侧 / 窗前 / 门口 / 走廊中段)
- **朝向**:面朝哪个方向(如:面向窗户 / 背对镜头 / 侧向病床 / 半侧身朝门口)
- **移动**:这一步是静止不动 / 小幅位移 / 转身;**并写清方位变化**
(如:从床边向窗户走两步 / 缓缓转身背对镜头)
对照写法:
❌ 「他在病房里说话」(观众不知道人和空间的关系 → 换机位就乱)
✅ 「他坐在病床右侧,面朝窗户,说到一半慢慢站起来转向门口」
⚠️ **单镜头内不要写长距离移动**(走 5 米这种,模型画不出来,会穿模/瞬移):
· 3 步以内的小幅位移 → 可以(如起身、上前一步、转身)
· 超过 3 步 / 跨越整个空间 → **必须拆成多个镜头**:
镜1 在床边起身(中景)→ 镜2 走向窗户(全景,能看出距离)→ 镜3 站在窗前(近景)
且**每个镜头用对应机位的场景参考图**,空间才连贯。
【拍摄风格与镜头视角纪律(★重要)】
拍摄风格决定"谁在看",镜头语言必须与之一致,不要写错视角:
- 📱 **手机自拍·第一人称(selfie_pov)**:镜头 = 人物自己举着的手机。
· 画面里是"他自己"(近脸、一臂距离、轻微广角畸变)
· 允许"看镜头说话"(对镜自述)
· ❌ 不要写第三人称观察视角(如"镜头从侧面拍他")
· ⚠️ **全程统一**:包括开场/过场镜,也只能是"人物自己的视角"
(如:举着手机照路 / 低头看自己脚下的鞋 / 手机屏幕反射中的自己),
不要出现"从身后跟拍脚步"这类旁人视角
- 🚶 **手持 Vlog·第一视角(vlog_handheld)**:镜头 = 拍摄者的眼睛。
· 画面里是"他看到的"(前方、手中物、同行的人)
· 拍摄者本人通常不入画,或只有手/手臂入画
- 📺 **访谈纪实(interview_doc)**:机位固定,人物半身对镜说话,可有停顿与思考
- 🎬 **电影感 / 伪纪录片**:第三人称观察视角,可用正反打 / 过肩镜头
{style_pack}
{device_pack}
{realism_discipline}
{realism}
{director_style}
【多角色一致性纪律(★重要,直接影响成片质量)】
- **同框限制**:图生视频只能喂「一张」角色参考图 → 多角色同框的近景/特写会让次要角色人脸随机漂移。
因此:
· 避免「两个以上角色同框且都要清晰露脸(近景/特写)」
· 双人戏优先用:**正反打**(单人镜头交替)· **过肩镜头**(一方背对或只露侧影)·
**中远景**(脸小、容错高)· **背影 / 手部 / 局部**
· 确需同框时:让"主角"占据画面视觉中心,次要角色可侧身、偏离焦平面或虚化处理
- **出场标注**:每个镜头用 `cast_in_shot` 精确标注**实际出场**的人物代号(没出场不要写)。
单人镜头只写一个人 —— 这是生成时绑定正确参考图(人脸一致)的前提。
【工作方法(先锁概念,再拆镜头)】
第一步:先在心里锁定整片的视觉概念(色调、光线基调、镜头语言、质感),保证所有镜头统一。
第二步:再按叙事顺序拆镜头,每个镜头要写清:
· 画面描述:谁在哪做什么、环境里有什么、光线方向与质感(要具体到可拍)
· 镜头契约:景别(特写/近景/中景/全景/远景)、机位角度(平视/俯视/仰视/过肩)、
摄影机运动(固定/推/拉/摇/跟/环绕)、景深(浅/中/深)、光学质感、帧率感(常规/升格/降格)
· 表演与情绪:情绪 + **微表情**(眼神/嘴部/眉宇各写一句)+ 人物动作
· 台词/旁白:像真人口语(没有就空)
· 声音设计:环境音提示(audio_cue);需要强调的音效点(sfx,写清第几秒+什么声音)
· 镜头衔接:与前后镜的转场(cut 硬切 / dissolve 溶接 / fade 淡变)
· **技术需求声明 tech_requirements**(★重要):只声明"这个镜头需要什么技术条件",不要指定用什么模型/工具:
- continuity:角色/服装/场景/表情/道具 各自要求(must 必须一致 / prefer 尽量 / none 不要求)
- chain_from_prev:是否要求画面承接上一镜的最后一帧(长镜头接续/无缝延续 → true)
- motion_level:动作幅度(static 静止 / small 小幅 / medium 中等 / large 大幅)
- camera_difficulty:运镜难度(fixed 固定 / simple 推拉摇 / complex 环绕跟拍等复杂调度)
- needs:其它特殊需求(自由文本,如"需要子弹时间特效""需要手部特写")
第三步:检查镜头之间是否连贯(同一场景/人物/风格),有无跳轴或突兀切换。
【输出格式】严格输出 JSON 数组,不要任何解释:
[
{{
"duration": 4,
"type": "action | talking_head | background | vfx",
"cast_in_shot": ["主角"],
"visual_description": "画面描述(具体到可拍)",
"shot_size": "景别", "angle": "机位角度", "camera_move": "摄影机运动",
"depth_of_field": "浅/中/深", "lens_feel": "光学质感", "frame_rate_feel": "常规/升格/降格",
"text": "台词或旁白(无则空字符串)",
"sentiment": "情绪",
"expression_detail": {{"eyes": "眼神", "mouth": "嘴部", "brow": "眉宇"}},
"action": "人物动作",
"audio_cue": "环境音提示(无则空字符串)",
"sfx": [{{"at_second": 1.2, "desc": "关门声"}}],
"transition_in": {{"type": "cut", "duration": 0}},
"transition_out": {{"type": "cut", "duration": 0}},
"speed": {{"factor": 1.0, "ramp": ""}},
"tech_requirements": {{
"continuity": {{"character": "must", "wardrobe": "must", "location": "must", "expression": "prefer", "props": "none"}},
"chain_from_prev": false,
"motion_level": "small",
"camera_difficulty": "simple",
"needs": []
}}
}}
]
"""
_REVISE_TMPL = """按导演级标准重新生成这一个镜头(保持整片视觉概念统一)。
【整片故事】{story}
【当前镜头(第 {idx} 个)】
{current}
【修改要求】{instruction}
【要求】只改这一个镜头,输出单个 JSON 对象(字段同上)。
"""
def skill_available() -> tuple[bool, str]:
"""技能文档是否就位 → (可用, 路径或原因)"""
for p in SKILL_PATHS:
if p.exists() and p.stat().st_size > 500:
return True, str(p)
return False, "技能文档未下载(需 ai-film-skills/docs/skills/zh-CN/ai-storyboard-director.md)"
def load_skill_doc() -> str:
ok, path = skill_available()
if not ok:
return ""
try:
txt = Path(path).read_text(encoding="utf-8")
return txt[:_SKILL_MAX_CHARS]
except Exception as e:
log.warning(" ⚠️ 技能文档读取失败: %s", e)
return ""
class StoryboardV52(BaseDirector):
"""AI Storyboard Director v5.2(技能型导演)"""
id = "storyboard_v52"
name = "AI Storyboard Director v5.2"
kind = "skill"
description = "导演级分镜:先锁视觉概念再拆镜头;镜头契约含景别/机位/光学/运动"
features = ["storyboard", "visual_concept", "shot_contract", "revise_shot"]
status = "ready"
source = "ai-film-skills(Apache-2.0)· docs/skills/zh-CN/ai-storyboard-director.md"
options_schema = dict(COMMON_OPTIONS)
def __init__(self):
self.llm = DirectorLLM(timeout=120)
@property
def available(self) -> bool:
ok, _ = skill_available()
return ok and self.llm.available
# ── 生成 ─────────────────────────────────────────
def generate(self, story: str, anchors: GlobalAnchors | None = None,
options: dict | None = None) -> Storyboard:
opts = options or {}
anchors = anchors or GlobalAnchors()
skill = load_skill_doc()
if not skill:
raise RuntimeError("v5.2 技能文档未就位,无法生成")
prompt = _PROMPT_TMPL.format(
shot_brief=shot_brief(opts),
story=(story or "").strip()[:2000],
shooting_style=opts.get("shooting_style") or "documentary_realism",
# ★ 资产注入:有详情用详情,否则退回 ID(让导演能写出"具体"的场景)
character=cast_or_character(anchors),
style_pack=style_pack_block(anchors),
device_pack=device_pack_hint(anchors),
realism_discipline=realism_discipline(),
realism=realism_block(),
director_style=director_style_hint(anchors),
location=anchors.location_context or anchors.location_id or "(未指定)",
wardrobe=anchors.wardrobe or "(未指定)",
props=anchors.props_context or "(无)",
)
raw = self.llm.chat(prompt, system=skill, temperature=0.7, max_tokens=3500)
items = self.llm.parse_json(raw)
if isinstance(items, dict):
items = items.get("shots") or items.get("data") or items.get("storyboard") or []
if not isinstance(items, list) or not items:
log.warning(" ⚠️ v5.2 未产出有效分镜")
return Storyboard(project_id=_pid(), story=story, director_id=self.id,
global_anchors=anchors, shots=[])
shots = build_shots_from_items(items, anchors)
durations = [s.duration for s in shots]
if len(set(durations)) <= 1 and len(shots) > 1:
log.warning(" ⚠️ 导演产出的镜头时长全部相同(%ss) — 可能未按内容差异化", durations[:1])
sb = Storyboard(
project_id=_pid(), title=(story or "")[:40], story=story,
content_type=opts.get("content_type") or "short_drama",
director_id=self.id, global_anchors=anchors, shots=shots,
)
log.info(" 🎬 v5.2 产出 %d 个镜头(%.1f 秒)", len(shots), sb.total_duration())
return sb
# ── 单镜头重生成 ──────────────────────────────────
def revise_shot(self, sb: Storyboard, shot_id: str,
instruction: str = "") -> Storyboard:
target = sb.shot_by_id(shot_id)
if not target:
raise ValueError(f"镜头不存在: {shot_id}")
skill = load_skill_doc()
import json as _json
prompt = _REVISE_TMPL.format(
story=(sb.story or "")[:800],
idx=sb.shots.index(target) + 1,
current=_json.dumps({
"visual_description": target.visual_description,
"shot_size": target.shot_contract.shot_size,
"angle": target.shot_contract.angle,
"camera_move": target.shot_contract.camera_move,
"text": target.text, "sentiment": target.sentiment,
}, ensure_ascii=False, indent=1),
instruction=instruction or "更有真实感与导演感",
)
raw = self.llm.chat(prompt, system=skill[:2000] if skill else "", temperature=0.8, max_tokens=900)
it = self.llm.parse_json(raw)
if isinstance(it, list) and it:
it = it[0]
if not isinstance(it, dict):
raise RuntimeError("重生成失败:LLM 未返回有效结果")
target.duration = float(it.get("duration") or target.duration)
target.type = _norm_type(it.get("type") or target.type)
target.visual_description = (it.get("visual_description") or target.visual_description).strip()
target.shot_contract = ShotContract(
shot_size=(it.get("shot_size") or "").strip(),
angle=(it.get("angle") or "").strip(),
camera_move=(it.get("camera_move") or "").strip(),
lens_feel=(it.get("lens_feel") or "").strip(),
depth_of_field=(it.get("depth_of_field") or "").strip(),
)
target.text = (it.get("text") or "").strip() or None
target.sentiment = (it.get("sentiment") or "").strip() or None
target.action = (it.get("action") or "").strip() or None
return sb
def _pid() -> str:
from datetime import datetime
return "vid_" + datetime.now().strftime("%Y%m%d_%H%M%S")
@@ -0,0 +1,40 @@
"""
gap_engine — 创意缺口引擎(GapEngine 落地包)
把"导演 1-2 句话的诉求"变成"一批可筛选的反差创意候选",再沉淀为「创意卡」资源。
模块:
models.py 数据模型(照原文 7 类 + 候选/创意卡扩展)
library.py 参数库 / 矩阵配置读写
gap_calculator.py 缺口计算(原文口径)+ 联想卡 + 矩阵过滤加权
combo_generator.py LLM 批量生成候选
store.py 创意卡存储(创意库)
数据资产(可直接编辑):
09_ave/config/param_library.yaml 六层参数库(6层/38参数/177值)
09_ave/config/creative_matrix.yaml 负面/正向/道具场景矩阵
"""
from gap_engine.models import ( # noqa: F401
CreativeCard, ComboCandidate, CreativeConstraints, GapResult,
Layer, Param, ParamLibrary, ParamValue, Selection,
)
from gap_engine.library import ( # noqa: F401
load_library, load_matrix, save_matrix, save_library_text,
constraints_from_matrix, generation_cfg,
)
from gap_engine.gap_calculator import ( # noqa: F401
calc_gap, build_scene_card, apply_filters, matrix_snapshot,
)
from gap_engine.combo_generator import ( # noqa: F401
generate_candidates, candidate_to_card, preselect_params,
)
from gap_engine import store # noqa: F401
__all__ = [
"ParamValue", "Param", "Layer", "ParamLibrary", "Selection", "GapResult",
"CreativeConstraints", "ComboCandidate", "CreativeCard",
"load_library", "load_matrix", "save_matrix", "save_library_text",
"constraints_from_matrix", "generation_cfg",
"calc_gap", "build_scene_card", "apply_filters", "matrix_snapshot",
"generate_candidates", "candidate_to_card", "preselect_params", "store",
]
@@ -0,0 +1,453 @@
"""
gap_engine.combo_generator — LLM 批量生成创意候选
流程(GapEngine「模块2 创意组合生成器」的落地实现):
1. 把参数库可选值清单 + 矩阵约束喂给 LLM
2. LLM 输出 N 条候选(每条 = 六层参数选择 + 一句话联想 + 道具场景 + 敏感项标注)
3. 用参数库把每条选择解析成 Selection(带 intensity + cue)
4. calc_gap 算缺口分 → apply_filters 硬过滤/软标记/正向加权 → 排序
输出即前端"密集候选列表"的数据源。
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
from typing import Optional
_SCRIPTS = Path(__file__).resolve().parent.parent
if str(_SCRIPTS) not in sys.path:
sys.path.insert(0, str(_SCRIPTS))
from lib.logger import get_logger # noqa: E402
from gap_engine.models import ( # noqa: E402
ComboCandidate, CreativeConstraints, ParamLibrary, Selection, utcnow,
)
from gap_engine.gap_calculator import calc_gap, apply_filters, build_scene_card # noqa: E402
from gap_engine.library import load_library, load_matrix, constraints_from_matrix, generation_cfg # noqa: E402
log = get_logger("gap_engine")
_SYSTEM = """你是短剧/广告导演的「反差组合生成器」。
你的任务:根据导演给出的 1-2 句话场景诉求,从给定的六层参数库里挑选参数组合,
生成一批**互不相同**的候选创意。每条候选必须能让人一眼脑补出画面。
硬性规则:
1. 只能从给定参数清单里取值,不得自创值名。
2. 每层最多选 2 个参数(不同候选之间要拉开差异)。
3. 绝不涉及"硬违禁"类内容。
4. 尽量避开"未放开的观众反感项";如果某条创意非用不可,把它标进 needs_soft。
5. 每条候选给一句 one_liner —— 用**人话**描述这个画面(导演据此判断想不想拍),
不要堆术语。例:「术后第一天,他扶着输液架走出六亲不认的模特步,背景放着迪斯科」。
6. 第七层「表达层」是"怎么表达":直述是默认;也可以用**暗喻/象征/反讽/留白**制造回味。
用隐喻时,喻体从「象征物」素材里挑(地震/潮水/笼中鸟/镜子…),**被比喻的事不要直接说出来**,
让观众自己完成那次替代。one_liner 里要能看出这层画面(但不必解释含义)。
【输出纪律(务必遵守,否则内容会被截断丢弃)】
- one_liner 控制在 **40 字以内**,一句话说清画面,不要展开解释。
- selections 每层**最多 1 个参数**(优先选最能体现这条差异的那个),总量 ≤ 7 条。
- 不要输出任何 JSON 之外的文字、注释或解释。
输出:严格 JSON 数组,不要解释文字。"""
_TMPL = """【场景诉求】
{brief}
【六层参数清单(只能从中取值)】
{param_list}
【硬违禁(绝对不可涉及)】
{hard_list}
【观众反感项(当前已放开的用 ✓ 标记;未放开的尽量避开,非用不可则标 needs_soft)】
{soft_list}
【可用道具】{props}
【可用场景变量】{scenes}
【希望传递的正向价值(命中则计入 positive_hits)】
{positive_list}
请生成 {n} 条候选创意,输出 JSON 数组,每条格式:
{{
"one_liner": "一句话画面联想(人话,能直接脑补)",
"selections": [
{{"layer_id": "narrative", "param_id": "info_release", "value": "结果前置"}},
... 每层 0~2 个 ...
],
"props": ["童趣道具"],
"scenes": ["空间反差"],
"needs_soft": [],
"needs_hard": [],
"positive_hits": ["warm_twist"]
}}"""
def parse_json_lenient(llm, raw: str):
"""容错解析:先走标准解析;失败则尝试修复"被 max_tokens 截断的 JSON"
截断修复思路:取到最后一个完整的 `}` 为止,补上 `]`(数组被截断的情形)。
这能救回"前面若干条完整、最后一条断掉"的常见情况。
"""
r = llm.parse_json(raw)
if r is not None:
return r
t = (raw or "").strip()
if not t:
return None
# 去掉 markdown 围栏(可能只有开头)
i0 = t.find("[")
if i0 < 0:
i0 = t.find("{")
if i0 < 0:
return None
body = t[i0:]
cut = body.rfind("}")
if cut <= 0:
return None
fixed = body[:cut + 1]
# 逐级补全:可能缺 ] 或 }
for tail in ("\n]", "]\n]", "\n]", ""):
try:
return json.loads(fixed + tail)
except Exception:
continue
# 再退一步:切到倒数第二个 } 再试
cut2 = body.rfind("}", 0, cut)
if cut2 > 0:
for tail in ("\n]", "]"):
try:
return json.loads(body[:cut2 + 1] + tail)
except Exception:
continue
return None
def _param_list_text(lib: ParamLibrary) -> str:
"""参数库 → 紧凑清单文本(只给 层/参数/可选值,不给 cue 以免 prompt 过长)"""
lines = []
for L in lib.layers:
lines.append(f"· {L.name}({L.id})")
for p in L.params:
vals = " / ".join(v.value for v in p.values)
lines.append(f" - {p.name}({p.id}): {vals}")
return "\n".join(lines)
def _matrix_texts(matrix: dict) -> tuple[str, str, str, str, str]:
neg = matrix.get("negative") or {}
hard = "、".join(f'{h.get("name")}({h.get("id")})' for h in (neg.get("hard") or [])) or "(无)"
soft_items = []
for s in (neg.get("soft") or []):
mark = "✓已放开" if s.get("relaxed") else "未放开"
soft_items.append(f'{s.get("name")}({s.get("id")}) [{mark}]')
soft = "\n".join(" - " + x for x in soft_items) or " (无)"
props = "、".join(f'{p.get("name")}' for p in (matrix.get("props") or [])) or "(无)"
scenes = "、".join(f'{s.get("name")}' for s in (matrix.get("scenes") or [])) or "(无)"
positive = "\n".join(f' - {p.get("name")}({p.get("id")})' for p in (matrix.get("positive") or [])) or " (无)"
return hard, soft, props, scenes, positive
def _resolve_selections(raw_sels: list[dict], lib: ParamLibrary,
max_per_layer: int = 2) -> list[Selection]:
"""把 LLM 的原始选择解析为 Selection(带 intensity + cue);丢弃库里不存在的值"""
per_layer: dict[str, int] = {}
out: list[Selection] = []
for rs in (raw_sels or []):
lid = str(rs.get("layer_id", "")).strip()
pid = str(rs.get("param_id", "")).strip()
val = str(rs.get("value", "")).strip()
if not (lid and pid and val):
continue
if max_per_layer and per_layer.get(lid, 0) >= max_per_layer:
continue
pv = lib.find_value(lid, pid, val)
if pv is None:
continue # LLM 编造的值 → 丢弃
per_layer[lid] = per_layer.get(lid, 0) + 1
out.append(Selection(
layer_id=lid, param_id=pid,
param_name=lib.param_name(lid, pid),
value=pv.value, intensity=pv.intensity, cue=pv.cue,
))
return out
def generate_candidates(brief: str, n: int = 0,
matrix: Optional[dict] = None,
lib: Optional[ParamLibrary] = None,
llm=None,
constraint_selections: Optional[list[dict]] = None) -> dict:
"""生成候选创意(完整流水线)
constraint_selections: 导演在 Tab① 定好的参数(作为硬框架约束每条候选);
为空 = 完全随机模式(AI 自由配参数)
返回 {candidates:[...], meta:{...}};candidates 已排序(未过滤在前、final_score 降序)
"""
brief = (brief or "").strip()
if not brief:
raise ValueError("需要输入场景诉求(1-2 句话)")
matrix = matrix or load_matrix()
lib = lib or load_library()
gen = generation_cfg(matrix)
n = int(n or gen.get("candidates_per_round") or 30)
max_per_layer = int(gen.get("max_params_per_layer") or 2)
if llm is None:
from directors.llm import DirectorLLM
llm = DirectorLLM(timeout=180)
if not getattr(llm, "available", True):
raise RuntimeError("LLM 未配置(请先在设置中配置大模型 Key)")
hard, soft, props, scenes, positive = _matrix_texts(matrix)
param_list = _param_list_text(lib)
# 参数框架块(按当前参数生成时使用)
constraint_block = ""
if constraint_selections:
_lines = []
for s in constraint_selections:
nm = s.get("param_name") or s.get("param_id", "")
_lines.append(f"· {nm}({s.get('layer_id','')}/{s.get('param_id','')})= {s.get('value','')}")
if _lines:
constraint_block = (
"\n【必须遵守的参数框架(导演已定,这是「做法」)】\n"
+ "\n".join(_lines)
+ "\n每条候选都**必须**使用这些参数值,在此框架内编不同的点子;"
"未列出的参数可以自由发挥以拉开差异。\n"
)
# 分批生成(单批 ≤ batch_size 条)——避免长输出被 max_tokens 截断导致整批丢弃
batch_size = max(5, int(gen.get("batch_size") or 15))
items: list = []
for start in range(0, n, batch_size):
bn = min(batch_size, n - len(items))
if bn <= 0:
break
p = _TMPL.format(
brief=brief, param_list=param_list, hard_list=hard,
soft_list=soft, props=props, scenes=scenes, positive_list=positive, n=bn,
)
if constraint_block:
p += constraint_block
if items:
p += f"\n\n(前面已给出 {len(items)} 条,本批 **必须不同**,不要重复前面的创意。)"
log.info("生成候选 第 %d 批(%d 条)…", start // batch_size + 1, bn)
raw = llm.chat(p, system=_SYSTEM, temperature=0.9, max_tokens=8000)
got = parse_json_lenient(llm, raw)
if isinstance(got, dict):
got = got.get("candidates") or got.get("combos") or got.get("data") or []
if not isinstance(got, list):
log.warning(" 第 %d 批解析失败(输出 %d 字符),跳过", start // batch_size + 1, len(raw or ""))
continue
items.extend(got)
if not items:
raise RuntimeError("LLM 未返回有效候选列表(可能是输出被截断,请减少条数后重试)")
constraints = constraints_from_matrix(matrix)
candidates: list[ComboCandidate] = []
seen: set[str] = set()
for i, it in enumerate(items):
if not isinstance(it, dict):
continue
sels = _resolve_selections(it.get("selections") or [], lib, max_per_layer)
if len(sels) < 2:
continue # 少于 2 个参数无法计算缺口(照原文校验)
one = (it.get("one_liner") or "").strip()
key = one or "|".join(f"{s.param_id}:{s.value}" for s in sels)
if key in seen:
continue # 去重
seen.add(key)
gap = calc_gap(sels)
# 关键联想点:取强度最高的 2 个 + 最低的 1 个(两端并置 = 反差所在)
_ss = sorted(sels, key=lambda s: -(s.intensity or 0))
_kp = []
if _ss:
_kp.append(_ss[0].cue)
if len(_ss) >= 3:
_kp.append(_ss[1].cue)
_kp.append(_ss[-1].cue)
elif len(_ss) == 2:
_kp.append(_ss[-1].cue)
key_points = [k for k in _kp if k][:3]
cand = ComboCandidate(
cid=f"c{i+1:02d}",
one_liner=one,
key_points=key_points,
selections=sels,
gap_score=gap.gap_score,
extreme_score=gap.extreme_score,
variance=gap.variance,
level=gap.level,
scene_card=gap.scene_card,
needs_soft=[str(x) for x in (it.get("needs_soft") or [])],
needs_hard=[str(x) for x in (it.get("needs_hard") or [])],
positive_hits=[str(x) for x in (it.get("positive_hits") or [])],
props=[str(x) for x in (it.get("props") or [])],
scenes=[str(x) for x in (it.get("scenes") or [])],
)
candidates.append(cand)
apply_filters(candidates, constraints, rebuild_card=False)
kept = [c for c in candidates if not c.filtered]
log.info("生成完成:%d 条(硬过滤掉 %d 条)", len(kept), len(candidates) - len(kept))
return {
"candidates": [c.model_dump() for c in candidates],
"meta": {
"brief": brief, "requested": n, "generated": len(candidates),
"mode": "constrained" if constraint_selections else "random",
"constraint_count": len(constraint_selections or []),
"kept": len(kept), "filtered": len(candidates) - len(kept),
"relaxed_soft": constraints.relaxed_soft_ids(),
"at": utcnow(),
},
}
def candidate_to_card(candidate: ComboCandidate, brief: str = "",
title: str = "", matrix: Optional[dict] = None,
inject_mode: str = "reference"):
"""候选 → 创意卡(入库用)"""
from gap_engine.models import CreativeCard
from gap_engine.gap_calculator import matrix_snapshot
m = matrix or load_matrix()
return CreativeCard(
title=title or (candidate.one_liner[:30] or brief[:30]),
one_liner=candidate.one_liner,
brief=brief,
selections=candidate.selections,
props=candidate.props, scenes=candidate.scenes,
gap_score=candidate.gap_score, variance=candidate.variance,
level=candidate.level, scene_card=candidate.scene_card,
positive_hits=candidate.positive_hits,
matrix_snapshot=matrix_snapshot(m),
inject_mode=inject_mode,
status="selected",
created_at=utcnow(), updated_at=utcnow(),
)
# ════════════════════════════════════════════════════════════
# AI 参数预选(Tab① 用:先给基础搭配,人再微调)
# ════════════════════════════════════════════════════════════
_PRESELECT_SYSTEM = """你是短剧/广告导演的「参数预选助手」。
导演不想从上百个参数里一个个挑。你要根据他的场景诉求 + 他设的矩阵边界,
直接给出一套**可直接开拍的基础参数搭配**(每层 1-2 个参数),他再在你的基础上微调。
要求:
1. 只能从给定参数清单里取值,不得自创。
2. 每层选 1-2 个参数(不必每层都选,但要覆盖叙事、表达、视觉、声音这几个主干)。
3. 目标是「反差较大但拍得出来」——不追求安全,也不故意猎奇。
4. 必须尊重矩阵边界:硬违禁绝不涉及;未放开的观众反感项避开;向已勾选的正向价值倾斜。
5. rationale 用一句人话说清"这套搭配想干嘛"。
【三套必须拉开截然不同的反差层级(这是重点)】
- 方案一「大胆」:整体远离常规——多个参数都取高强度值,让整条片子"质地异样"(如:象征+大特写+长沉默+逆光)。
- 方案二「落差」:把常规项与反常规项并置,制造"预期落差"(如:严肃题材 + 儿歌 BGM)。
- 方案三「克制」:整体贴近真实,只在一处藏一个小反常,靠细节取胜。
三套的**参数选择要明显不同**,让导演一眼能选出方向;不要三套都用同一种思路。
【输出纪律(务必遵守,否则内容会被截断丢弃)】
- rationale 控制在 **40 字以内**。
- 每套 selections **每层最多 1 个参数**,总量 5-7 条。
- 不要输出任何 JSON 之外的文字、注释、解释。
输出严格 JSON 数组。"""
_PRESELECT_TMPL = """【场景诉求】
{brief}
【六层参数清单(只能从中取值)】
{param_list}
【硬违禁(绝不涉及)】
{hard_list}
【观众反感项(✓=已放开,可用来制造反差;未放开=避开)】
{soft_list}
【希望倾斜的正向价值】
{positive_list}
请给出 {n} 套**基础搭配方案**(风格拉开差异,让导演一眼能选出方向),
输出 JSON 数组:
[
{{
"name": "方案A · 短标题(如:冷眼旁观式)",
"rationale": "一句话说明这套搭配的思路与效果",
"selections": [
{{"layer_id": "narrative", "param_id": "info_release", "value": "结果前置"}},
... 每层 1~2 个 ...
]
}}
]"""
def preselect_params(brief: str, matrix: Optional[dict] = None,
lib: Optional[ParamLibrary] = None,
llm=None, n_schemes: int = 3) -> dict:
"""AI 预选:按场景 + 矩阵给 N 套七层参数基础搭配(含缺口分)
返回 {schemes:[{name, rationale, selections, gap_score, variance, level, scene_card}], meta}
"""
brief = (brief or "").strip()
if not brief:
raise ValueError("需要先填写场景诉求(1-2 句话)")
matrix = matrix or load_matrix()
lib = lib or load_library()
if llm is None:
from directors.llm import DirectorLLM
llm = DirectorLLM(timeout=180)
if not getattr(llm, "available", True):
raise RuntimeError("LLM 未配置(请先在设置中配置大模型 Key)")
hard, soft, _props, _scenes, positive = _matrix_texts(matrix)
prompt = _PRESELECT_TMPL.format(
brief=brief, param_list=_param_list_text(lib), hard_list=hard,
soft_list=soft, positive_list=positive, n=max(1, int(n_schemes)),
)
log.info("AI 预选参数…(场景: %s)", brief[:40])
raw = llm.chat(prompt, system=_PRESELECT_SYSTEM, temperature=0.8, max_tokens=4000)
items = parse_json_lenient(llm, raw)
if isinstance(items, dict):
items = items.get("schemes") or items.get("plans") or items.get("data") or []
if not isinstance(items, list):
raise RuntimeError("LLM 未返回有效方案")
gen = generation_cfg(matrix)
max_per_layer = int(gen.get("max_params_per_layer") or 2)
schemes: list[dict] = []
for it in items:
if not isinstance(it, dict):
continue
sels = _resolve_selections(it.get("selections") or [], lib, max_per_layer)
if len(sels) < 2:
continue
g = calc_gap(sels)
schemes.append({
"name": (it.get("name") or f"方案{len(schemes)+1}")[:40],
"rationale": (it.get("rationale") or "").strip(),
"selections": [s.model_dump() for s in sels],
"gap_score": g.gap_score,
"extreme_score": g.extreme_score,
"variance": g.variance,
"level": g.level,
"scene_card": g.scene_card,
})
if not schemes:
raise RuntimeError("预选失败:未得到有效搭配")
schemes.sort(key=lambda s: -(s.get("gap_score") or 0))
log.info("预选完成:%d 套(最高缺口 %.1f)", len(schemes), schemes[0]["gap_score"])
return {"schemes": schemes, "meta": {"brief": brief, "count": len(schemes), "at": utcnow()}}
@@ -0,0 +1,168 @@
"""
gap_engine.gap_calculator — 缺口计算 + 联想卡 + 矩阵过滤/加权
缺口算法(照 GapEngine 原文口径,未改):
intensities = 被选中参数的 intensity 列表(每层选 0-2 个,不选=中性,不进计算)
variance = statistics.variance(intensities)
gap_score = min(100, variance * 400) # 理论最大 0.25 × 400 = 100
high_count = intensity >= 0.6 的个数
low_count = intensity <= 0.3 的个数
scene_card = 按层序拼接各值的 cue + 依方差给「缺口显著/中等/微弱」总结
过滤/加权(原文设计了模块但无代码,此处实现):
硬过滤 = 候选涉及 🔴 锁定项 → 淘汰
软风险 = 候选需要放开某 🟡 项但用户未放开 → 标记(不淘汰)
正向加权= 命中正向项 → final_score = gap_score + Σ bonus
"""
from __future__ import annotations
import statistics
import sys
from pathlib import Path
from typing import Optional
_SCRIPTS = Path(__file__).resolve().parent.parent
if str(_SCRIPTS) not in sys.path:
sys.path.insert(0, str(_SCRIPTS))
from lib.logger import get_logger # noqa: E402
from gap_engine.models import ( # noqa: E402
ComboCandidate, CreativeConstraints, GapResult, Selection,
)
log = get_logger("gap_engine")
# 层顺序(联想卡按此顺序拼接,照原文)
LAYER_ORDER = ["narrative", "rhetoric", "visual", "motion", "lighting", "performance", "sound"]
# 缺口强弱阈值(照原文:variance > 0.06 显著 / > 0.03 中等 / 否则微弱)
LEVEL_HIGH = 0.06
LEVEL_MID = 0.03
# ════════════════════════════════════════════════════════════
# 缺口计算(照原文)
# ════════════════════════════════════════════════════════════
def calc_gap(selections: list[Selection]) -> GapResult:
"""一次选择的缺口计算"""
intensities = [s.intensity for s in selections]
if len(intensities) < 2:
variance = 0.0
else:
variance = statistics.variance(intensities)
gap_score = min(100.0, variance * 400)
# 极端度:各选中值偏离"中性预期(0.5)"的平均程度 —— 捕捉"整体异质"型反差
# (全取高反常规值时方差小但异质度高,原文方差法会漏掉这类)
if intensities:
extreme_score = min(100.0, (sum(abs(i - 0.5) for i in intensities) / len(intensities)) * 200)
else:
extreme_score = 0.0
high_count = sum(1 for i in intensities if i >= 0.6)
low_count = sum(1 for i in intensities if i <= 0.3)
card, level = build_scene_card(selections)
return GapResult(
selections=list(selections),
variance=round(variance, 4),
gap_score=round(gap_score, 1),
extreme_score=round(extreme_score, 1),
high_count=high_count,
low_count=low_count,
scene_card=card,
level=level,
)
def build_scene_card(selections: list[Selection]) -> tuple[str, str]:
"""把选中的参数值翻译成一段连贯的画面描述(照原文),返回 (卡片文本, 强弱级别)"""
sorted_sels = sorted(
selections,
key=lambda s: LAYER_ORDER.index(s.layer_id) if s.layer_id in LAYER_ORDER else 99,
)
body = "。".join(s.cue for s in sorted_sels if s.cue)
if body and not body.endswith("。"):
body += "。"
intensities = [s.intensity for s in selections]
variance = statistics.variance(intensities) if len(intensities) >= 2 else 0.0
if variance > LEVEL_HIGH:
level = "显著"
summary = ("\n\n【缺口显著】这个组合的参数预期强度差异很大,"
"观众会在「预期」和「实际」之间产生强烈的认知落差。")
elif variance > LEVEL_MID:
level = "中等"
summary = ("\n\n【缺口中等】有反差但不极端,"
"适合需要平衡真实感和吸引力的场景。")
else:
level = "微弱"
summary = ("\n\n【缺口微弱】参数预期一致,画面和谐但可能缺乏冲击力。"
"如果想制造反差,需要调整某个参数的方向。")
return body + summary, level
# ════════════════════════════════════════════════════════════
# 矩阵过滤 + 正向加权(新实现)
# ════════════════════════════════════════════════════════════
def apply_filters(candidates: list[ComboCandidate],
constraints: CreativeConstraints,
rebuild_card: bool = True) -> list[ComboCandidate]:
"""对候选做硬过滤 + 软风险标记 + 正向加权,并排序(未过滤在前,final_score 降序)"""
relaxed = set(constraints.relaxed_soft_ids())
locked = set(constraints.locked_hard_ids())
hard_name = {h["id"]: h.get("name", h["id"]) for h in constraints.negative_hard}
soft_name = {s["id"]: s.get("name", s["id"]) for s in constraints.negative_soft}
bonus_map = {p["id"]: float(p.get("bonus", 8) or 8) for p in constraints.positive}
pos_name = {p["id"]: p.get("name", p["id"]) for p in constraints.positive}
for c in candidates:
# ① 硬过滤:涉及 🔴 锁定项 → 淘汰
hard_hit = [h for h in (c.needs_hard or []) if h in locked]
if hard_hit:
c.filtered = True
c.filter_reason = "涉及硬违禁:" + "、".join(hard_name.get(h, h) for h in hard_hit)
c.final_score = 0.0
continue
# ② 软风险:需要放开 🟡 项但未放开 → 标记(不淘汰)
c.risks = []
for s in (c.needs_soft or []):
if s in relaxed:
continue
c.risks.append({
"kind": "soft", "id": s, "name": soft_name.get(s, s),
"reason": f'需要放开"{soft_name.get(s, s)}"才能拍(当前未放开)',
})
# ③ 正向加权
c.bonus = sum(bonus_map.get(p, 0) for p in (c.positive_hits or []))
c.positive_hits = [p for p in (c.positive_hits or []) if p in pos_name]
c.final_score = round(max(c.gap_score or 0, c.extreme_score or 0) + c.bonus, 1)
# ④ 重算联想卡(若调用方未生成)
if rebuild_card and not c.scene_card and c.selections:
card, level = build_scene_card(c.selections)
c.scene_card = card
c.level = c.level or level
candidates.sort(key=lambda x: (x.filtered, -(x.final_score or 0)))
return candidates
def matrix_snapshot(matrix: dict) -> dict:
"""记录本次使用的矩阵(含放开了哪些 🟡)——存进创意卡供追溯"""
neg = matrix.get("negative") or {}
soft = neg.get("soft") or []
pos = matrix.get("positive") or []
return {
"generation": matrix.get("generation") or {},
"negative_hard": [h.get("id") for h in (neg.get("hard") or [])],
"negative_soft_relaxed": [s.get("id") for s in soft if s.get("relaxed")],
"negative_soft_excluded": [s.get("id") for s in soft if not s.get("relaxed")],
"positive": [p.get("id") for p in pos if p.get("enabled", True)],
"preset": matrix.get("active_preset", ""),
"at": __import__("datetime").datetime.now().isoformat(timespec="seconds"),
}
@@ -0,0 +1,110 @@
"""
gap_engine.library — 参数库与矩阵配置的读写
数据来源(都可直接编辑,改动即时生效):
09_ave/config/param_library.yaml 六层参数库(6层/38参数/177值)
09_ave/config/creative_matrix.yaml 矩阵配置(负面/正向/道具场景/生成参数)
"""
from __future__ import annotations
import sys
from pathlib import Path
from typing import Optional
import yaml
_SCRIPTS = Path(__file__).resolve().parent.parent
if str(_SCRIPTS) not in sys.path:
sys.path.insert(0, str(_SCRIPTS))
from lib.logger import get_logger # noqa: E402
from gap_engine.models import ParamLibrary, CreativeConstraints # noqa: E402
log = get_logger("gap_engine")
_AVE_DIR = _SCRIPTS.parent
LIB_PATH = _AVE_DIR / "config" / "param_library.yaml"
MATRIX_PATH = _AVE_DIR / "config" / "creative_matrix.yaml"
# 简易缓存(按文件 mtime 失效)
_cache: dict = {}
def _load_yaml(path: Path) -> dict:
try:
return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
except Exception as e:
log.warning("读取 %s 失败: %s", path.name, e)
return {}
def load_library(use_cache: bool = True) -> ParamLibrary:
"""加载六层参数库(按 mtime 自动失效缓存)"""
key = "library"
try:
mtime = LIB_PATH.stat().st_mtime
except Exception:
mtime = 0
if use_cache and _cache.get(key, {}).get("mtime") == mtime:
return _cache[key]["obj"]
d = _load_yaml(LIB_PATH)
lib = ParamLibrary(**d) if d.get("layers") else ParamLibrary(layers=[])
_cache[key] = {"mtime": mtime, "obj": lib}
return lib
def load_matrix(use_cache: bool = True) -> dict:
"""加载矩阵配置(原始 dict,含 generation / negative / positive / props / scenes / presets)"""
key = "matrix"
try:
mtime = MATRIX_PATH.stat().st_mtime
except Exception:
mtime = 0
if use_cache and _cache.get(key, {}).get("mtime") == mtime:
return _cache[key]["obj"]
d = _load_yaml(MATRIX_PATH)
_cache[key] = {"mtime": mtime, "obj": d}
return d
def save_matrix(data: dict) -> dict:
"""保存矩阵配置(前端 ②③④ 标签页的勾选结果)"""
data = dict(data or {})
data["updated"] = __import__("datetime").date.today().isoformat()
MATRIX_PATH.write_text(
yaml.safe_dump(data, allow_unicode=True, sort_keys=False, width=100),
encoding="utf-8")
_cache.pop("matrix", None)
log.info("矩阵配置已保存")
return load_matrix(use_cache=False)
def save_library_text(text: str) -> dict:
"""保存参数库(整份 YAML 文本;供高级编辑用)"""
parsed = yaml.safe_load(text)
if not parsed or not parsed.get("layers"):
raise ValueError("参数库格式错误:缺少 layers")
LIB_PATH.write_text(text, encoding="utf-8")
_cache.pop("library", None)
lib = load_library(use_cache=False)
n_param = sum(len(L.params) for L in lib.layers)
n_val = sum(len(p.values) for L in lib.layers for p in L.params)
return {"layers": len(lib.layers), "params": n_param, "values": n_val}
def constraints_from_matrix(matrix: Optional[dict] = None) -> CreativeConstraints:
"""矩阵 dict → CreativeConstraints(供过滤/加权使用)"""
m = matrix or load_matrix()
neg = m.get("negative") or {}
return CreativeConstraints(
negative_hard=neg.get("hard") or [],
negative_soft=neg.get("soft") or [],
positive=m.get("positive") or [],
props=m.get("props") or [],
scenes=m.get("scenes") or [],
)
def generation_cfg(matrix: Optional[dict] = None) -> dict:
m = matrix or load_matrix()
return m.get("generation") or {}
@@ -0,0 +1,160 @@
"""
gap_engine.models — 数据模型(照 GapEngine 原文,含创意卡扩展)
原文 7 个类:ParamValue / Param / Layer / ParamLibrary / Selection / GapResult / CreativeConstraints
扩展:ComboCandidate(AI 生成的候选组合)、CreativeCard(入库的创意卡)
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Optional
from pydantic import BaseModel, Field
# ════════════════════════════════════════════════════════════
# 参数库(照原文)
# ════════════════════════════════════════════════════════════
class ParamValue(BaseModel):
"""单个参数值:值名 + 预期强度 + 画面联想描述"""
value: str
intensity: float = 0.5
cue: str = ""
class Param(BaseModel):
id: str
name: str
values: list[ParamValue] = Field(default_factory=list)
class Layer(BaseModel):
id: str
name: str
params: list[Param] = Field(default_factory=list)
class ParamLibrary(BaseModel):
layers: list[Layer] = Field(default_factory=list)
def find_value(self, layer_id: str, param_id: str, value: str) -> Optional[ParamValue]:
for L in self.layers:
if L.id != layer_id:
continue
for p in L.params:
if p.id != param_id:
continue
for v in p.values:
if v.value == value:
return v
return None
def param_name(self, layer_id: str, param_id: str) -> str:
for L in self.layers:
if L.id == layer_id:
for p in L.params:
if p.id == param_id:
return p.name
return param_id
# ════════════════════════════════════════════════════════════
# 选择与缺口(照原文)
# ════════════════════════════════════════════════════════════
class Selection(BaseModel):
"""一次选择:某个参数取某个值"""
layer_id: str
param_id: str
param_name: str = ""
value: str
intensity: float
cue: str = ""
class GapResult(BaseModel):
"""缺口计算结果(字段口径照原文)"""
selections: list[Selection] = Field(default_factory=list)
variance: float = 0.0
gap_score: float = 0.0 # 0-100(方差 × 400 归一,原文口径:落差型反差)
extreme_score: float = 0.0 # 0-100(平均偏离中性 0.5,补足"整体异质"型反差)
high_count: int = 0 # intensity >= 0.6
low_count: int = 0 # intensity <= 0.3
scene_card: str = "" # 画面联想卡
level: str = "" # 显著 / 中等 / 微弱
class CreativeConstraints(BaseModel):
"""矩阵配置(负面/正向/道具场景)—— 本文档不含生成参数"""
negative_hard: list[dict] = Field(default_factory=list)
negative_soft: list[dict] = Field(default_factory=list)
positive: list[dict] = Field(default_factory=list)
props: list[dict] = Field(default_factory=list)
scenes: list[dict] = Field(default_factory=list)
def relaxed_soft_ids(self) -> list[str]:
return [s["id"] for s in self.negative_soft if s.get("relaxed")]
def locked_hard_ids(self) -> list[str]:
return [s["id"] for s in self.negative_hard if s.get("locked")]
# ════════════════════════════════════════════════════════════
# 候选组合(AI 生成,扩展)
# ════════════════════════════════════════════════════════════
class ComboCandidate(BaseModel):
"""一条候选创意(= 一次完整选择 + 缺口分 + 一句话联想 + 风险标记)"""
cid: str = "" # 候选 id(批次内唯一)
one_liner: str = "" # 核心产物:一句话联想(导演据此脑补)
selections: list[Selection] = Field(default_factory=list)
key_points: list[str] = Field(default_factory=list) # 关键联想点(不展开就能判断)
gap_score: float = 0.0
extreme_score: float = 0.0 # 整体异质度(全高强度也可能是强反差)
variance: float = 0.0
level: str = ""
scene_card: str = ""
risks: list[dict] = Field(default_factory=list) # [{kind:'soft'|'hard', id, name, reason}]
needs_soft: list[str] = Field(default_factory=list) # LLM 标注:本条需要放开哪些 🟡 项(id)
needs_hard: list[str] = Field(default_factory=list) # LLM 标注:本条涉及哪些 🔴 项(id)→ 命中即淘汰
positive_hits: list[str] = Field(default_factory=list) # 命中的正向项 id
bonus: float = 0.0 # 正向加权分
final_score: float = 0.0 # gap_score + bonus
props: list[str] = Field(default_factory=list) # 道具(名称)
scenes: list[str] = Field(default_factory=list) # 场景变量(名称)
filtered: bool = False # 是否被硬过滤淘汰
filter_reason: str = ""
# ════════════════════════════════════════════════════════════
# 创意卡(入库,扩展)
# ════════════════════════════════════════════════════════════
INJECT_MODES = ("strict", "reference", "idea_only")
class CreativeCard(BaseModel):
"""创意卡 = 入创意库的一条创意(可被分镜/口播/脚本流程引用)"""
id: str = ""
title: str = "" # 主题(一句话)
one_liner: str = "" # 一句话联想
brief: str = "" # 原始输入的 1-2 句话
selections: list[Selection] = Field(default_factory=list)
props: list[str] = Field(default_factory=list)
scenes: list[str] = Field(default_factory=list)
gap_score: float = 0.0
variance: float = 0.0
level: str = ""
scene_card: str = ""
positive_hits: list[str] = Field(default_factory=list)
matrix_snapshot: dict = Field(default_factory=dict) # 本次矩阵(含放开了哪些🟡)
inject_mode: str = "reference" # strict 严格约束 / reference 参考 / idea_only 仅创意
status: str = "draft" # draft 草稿 / selected 已选 / used 已用于
used_by: list[dict] = Field(default_factory=list) # [{flow, run_id, at}]
created_at: str = ""
updated_at: str = ""
def utcnow() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
@@ -0,0 +1,122 @@
"""
gap_engine.store — 创意卡存储(创意库的持久化)
位置:agent-local/tools/ave/creatives/{id}.json(运行时数据,非人工编辑)
一张卡一个文件,便于 git 之外的本机私有积累与迁移。
"""
from __future__ import annotations
import json
import sys
import uuid
from pathlib import Path
from typing import Optional
_SCRIPTS = Path(__file__).resolve().parent.parent
if str(_SCRIPTS) not in sys.path:
sys.path.insert(0, str(_SCRIPTS))
from lib.logger import get_logger # noqa: E402
from gap_engine.models import CreativeCard, utcnow # noqa: E402
log = get_logger("gap_engine")
_AGENT_LOCAL = Path(__import__("os").environ.get(
"AGENT_LOCAL", Path.home() / "workbuddy-agent-os" / "agent-local"))
_DIR = _AGENT_LOCAL / "tools" / "ave" / "creatives"
def _ensure_dir() -> Path:
_DIR.mkdir(parents=True, exist_ok=True)
return _DIR
def _path(cid: str) -> Path:
return _ensure_dir() / f"{cid}.json"
def create(card: CreativeCard) -> dict:
"""新建创意卡(自动生成 id 与时间戳)"""
if not card.id:
card.id = "cre_" + uuid.uuid4().hex[:10]
now = utcnow()
card.created_at = card.created_at or now
card.updated_at = now
_path(card.id).write_text(
json.dumps(card.model_dump(), ensure_ascii=False, indent=1), encoding="utf-8")
log.info("创意卡入库: %s(%s)", card.id, card.title[:30])
return card.model_dump()
def get(cid: str) -> Optional[dict]:
p = _path(cid)
if not p.exists():
return None
try:
return json.loads(p.read_text(encoding="utf-8"))
except Exception as e:
log.warning("读取创意卡失败 %s: %s", cid, e)
return None
def update(cid: str, patch: dict) -> Optional[dict]:
cur = get(cid)
if not cur:
return None
cur.update({k: v for k, v in (patch or {}).items() if k not in ("id", "created_at")})
cur["updated_at"] = utcnow()
_path(cid).write_text(json.dumps(cur, ensure_ascii=False, indent=1), encoding="utf-8")
return cur
def delete(cid: str) -> bool:
p = _path(cid)
if p.exists():
p.unlink()
return True
return False
def list_cards(q: str = "", status: str = "", inject_mode: str = "",
limit: int = 200, sort: str = "updated_desc") -> list[dict]:
"""创意卡列表(支持关键词/状态/注入模式筛选)"""
out: list[dict] = []
for f in _ensure_dir().glob("cre_*.json"):
try:
d = json.loads(f.read_text(encoding="utf-8"))
except Exception:
continue
if status and d.get("status") != status:
continue
if inject_mode and d.get("inject_mode") != inject_mode:
continue
if q:
blob = " ".join(str(d.get(k, "")) for k in ("title", "one_liner", "brief", "scene_card"))
blob += " " + " ".join(d.get("props") or []) + " " + " ".join(d.get("scenes") or [])
if q not in blob:
continue
out.append(d)
key = {
"updated_desc": lambda d: d.get("updated_at", ""),
"gap_desc": lambda d: -(d.get("gap_score") or 0),
"created_desc": lambda d: d.get("created_at", ""),
}.get(sort, lambda d: d.get("updated_at", ""))
reverse = sort != "gap_desc"
out.sort(key=key, reverse=reverse)
return out[:limit]
def stats() -> dict:
cards = list_cards(limit=10000)
by_status: dict[str, int] = {}
by_mode: dict[str, int] = {}
for c in cards:
by_status[c.get("status", "draft")] = by_status.get(c.get("status", "draft"), 0) + 1
by_mode[c.get("inject_mode", "reference")] = by_mode.get(c.get("inject_mode", "reference"), 0) + 1
gaps = [c.get("gap_score") or 0 for c in cards]
return {
"total": len(cards),
"by_status": by_status,
"by_inject_mode": by_mode,
"avg_gap": round(sum(gaps) / len(gaps), 1) if gaps else 0,
}
@@ -0,0 +1,91 @@
# ============================================
# AVE 演示:励志金句合集
# ============================================
meta:
project_id: "vid_inspire_quotes_001"
style: "knowledge_lecture"
voice:
provider: "aliyun"
voice_id: "cosyvoice-v3.5-plus-bailian-f92cac2a41ce4fc78153c6097755a60e"
default_emotion: "温暖坚定"
bgm:
provider: "mlx-audiocraft"
mood: "inspiring"
output:
resolution: "1080x1920"
fps: 30
avatar:
image: "~/agent-local/tools/ave/avatar/ghai_avatar.jpg"
opening_text: "你好,今天分享几句让我醍醐灌顶的话。"
closing_text: "关注我,一起聆听世界,我们下期再见"
segments:
- id: 1
text: "种一棵树最好的时间是十年前,其次是现在。"
duration_sec: 6
voice_emotion: "坚定"
camera: "slow_zoom_in"
material:
source: "pexels"
search: "trees sunlight forest nature"
subtitles: true
- id: 2
text: "人生没有白走的路,每一步都算数。"
duration_sec: 5
voice_emotion: "温暖"
camera: "static"
material:
source: "pexels"
search: "mountain path hiking trail"
subtitles: true
- id: 3
text: "不要等待机会,而要创造机会。"
duration_sec: 5
voice_emotion: "鼓励"
camera: "push_in"
material:
source: "pexels"
search: "sunrise sky hopeful"
subtitles: true
- id: 4
text: "你的潜力远比你想象的要大得多。"
duration_sec: 5
voice_emotion: "深沉有力"
camera: "slow_zoom_out"
material:
source: "pexels"
search: "stars night sky universe"
subtitles: true
- id: 5
text: "成功的秘诀就是坚持做对的事,哪怕进展缓慢。"
duration_sec: 7
voice_emotion: "温和而坚定"
camera: "slow_pan_right"
material:
source: "pexels"
search: "ocean waves calm beach"
subtitles: true
- id: 6
text: "当你觉得为时已晚的时候,恰恰是最早的时候。"
duration_sec: 6
voice_emotion: "启发"
camera: "static"
material:
source: "pexels"
search: "sunset golden clouds hope"
subtitles: true
- id: 7
text: "相信自己,你比你想象的更优秀。"
duration_sec: 5
voice_emotion: "温暖收尾"
camera: "slow_zoom_in"
material:
source: "pexels"
search: "mountain top view inspiring"
subtitles: true
@@ -0,0 +1,283 @@
#!/usr/bin/env python3
"""关键帧生成 —— 千问AI平台(DashScope)多图融合
2026-10-04 实测确认可用(¥0.2/张):
· 端点 https://maas.qianwenaiapi.com/api/v1/services/aigc/multimodal-generation/generation
· 模型 wan2.7-image-pro(最多 9 张输入图 / 顺序模式一次 12 张)
· 格式 input.messages[0].content[] = [{text}, {image}, {image}, ...]
· 图片 支持 base64 data URL(无需先上传 OSS);实测 769×1362 竖屏
用法(CLI 自测,会花钱):
python3 scripts/keyframe_gen.py --role <角色图> --scene <场景图> \
--prompt "把图1的人放进图2的房间里,中景,平视" --out /tmp/kf.png
python3 scripts/keyframe_gen.py --text-only --prompt "空旷病房" --out /tmp/kf.png
"""
from __future__ import annotations
import argparse
import base64
import json
import os
import sys
import urllib.error
import urllib.request
from pathlib import Path
SCRIPTS = Path(__file__).resolve().parent
if str(SCRIPTS) not in sys.path:
sys.path.insert(0, str(SCRIPTS))
from lib.http import _NO_PROXY_OPENER # noqa: E402
from lib.logger import get_logger # noqa: E402
log = get_logger("keyframe_gen")
API = "https://maas.qianwenaiapi.com/api/v1/services/aigc/multimodal-generation/generation"
DEFAULT_MODEL = "wan2.7-image-pro" # 9 图输入 / 顺序 12 张;备选 qwen-image-3.0-pro
UA = {"User-Agent": "Mozilla/5.0 (AgentOS-Keyframe)"}
# ── 画幅预设(官方文档实测:size 格式为 `宽*高`,不是只有 1K/2K/4K 档位)──
# qwen-image 系列:宽高自由 512~2048
# wan2.5+:总像素 1280² ~ 1440²(推荐 9:16 = 960*1696)
# wan2.2 及更早:边长 512~1440 · FLUX:仅几个固定值
SIZE_PRESETS = {
"竖屏 9:16 · 720P(测试推荐)": "720*1280",
"竖屏 9:16 · 标准": "960*1696",
"竖屏 9:16 · 1080P": "1080*1920",
"方图 1:1": "1024*1024",
"横屏 16:9 · 720P": "1280*720",
"横屏 16:9 · 标准": "1696*960",
"省算力 · 小图": "768*1024",
}
DEFAULT_SIZE = "720*1280" # 竖屏 720P —— 测试期默认,省算力
# (正式出片可切 960*1696 或 1080*1920)
# ── 模型注册表(config/image_models.yaml)────────────────────
def _models_yaml() -> Path:
return Path(os.environ.get("AVE_IMAGE_MODELS")
or (SCRIPTS.parent / "config" / "image_models.yaml"))
def image_models() -> list[dict]:
"""读取模型注册表(含能力注释)。读不到时回退到内置最小集合。"""
try:
import yaml
d = yaml.safe_load(_models_yaml().read_text(encoding="utf-8")) or {}
ms = d.get("models") or []
if ms:
return ms
except Exception as e:
log.warning("读取 image_models.yaml 失败: %s", e)
return [{"id": DEFAULT_MODEL, "name": DEFAULT_MODEL, "vendor": "百炼(千问AI平台)",
"price": "¥0.2/张", "note": "内置回退项", "supports": ["multi", "single", "text2img"],
"limits": {}, "key_field": "aliyun"}]
def models_for_mode(mode: str) -> list[dict]:
"""某生成方式可用的模型(按 supports 过滤)"""
m = (mode or "").strip()
return [x for x in image_models() if m in (x.get("supports") or [])]
def model_info(mid: str) -> dict:
for x in image_models():
if x.get("id") == mid:
return x
return {}
# ── 按「生成方式」选模型 ──────────────────────────────────
# 多图融合 / 文生图 / 图生图 → wan2.7-image-pro(9 图输入,融合强)
# 改造已有图(结构保持重绘)→ qwen-image-edit-plus(指令编辑,保结构)
MODEL_BY_MODE = {
"multi": "wan2.7-image-pro",
"text2img": "wan2.7-image-pro",
"single": "wan2.7-image-pro",
"edit": "qwen-image-edit-plus",
}
# ⚠️ 官方示例里 qwen-image 系列的 content 顺序是「先 image 后 text」,
# 而 wan 系列是「先 text 后 image」。混用会导致指令被忽略。
IMAGE_FIRST_PREFIXES = ("qwen-image",)
def model_for_mode(mode: str) -> str:
return MODEL_BY_MODE.get((mode or "").strip(), DEFAULT_MODEL)
def _api_key() -> str:
"""读取百炼/千问平台 key(agent-local 私有配置)"""
import yaml
p = os.environ.get("AVE_LOCAL_CONFIG") or os.path.expanduser(
"~/workbuddy-agent-os/agent-local/tools/ave/config/local.yaml")
try:
return ((yaml.safe_load(open(p)) or {}).get("aliyun") or {}).get("api_key", "")
except Exception as e:
log.warning("读取 key 失败: %s", e)
return ""
def _assets_dir() -> Path:
"""资产库根目录(与 Dashboard 的 AssetStore 保持一致)"""
return Path(os.environ.get("AVE_ASSETS_DIR")
or os.path.expanduser("~/workbuddy-agent-os/agent-local/tools/ave/assets"))
def _url_to_local(s: str) -> Path | None:
"""把「后端 URL 形式」还原成本地文件路径
前端资产选择器给出的是可访问 URL:
/api/ave/assets/{kind}/{aid}/file/{name}
它与磁盘上的
{assets}/{kind}/{aid}/{name}
是同一个文件。这里做还原(含 URL 解码),并保证不越出资产目录。
"""
from urllib.parse import unquote
if not s.startswith("/api/ave/assets/"):
return None
parts = [x for x in s.split("/") if x] # api ave assets kind aid file name
if len(parts) < 7 or parts[5] != "file":
return None
kind, aid, name = (unquote(parts[3]), unquote(parts[4]), unquote(parts[6]))
base = _assets_dir().resolve()
cand = (base / kind / aid / name)
try:
cand = cand.resolve()
if not str(cand).startswith(str(base)): # 防路径穿越
return None
except Exception:
return None
return cand if cand.exists() else None
def _as_image_value(path_or_url: str) -> str:
"""参考图 → 可提交给模型的值
支持四种输入:
1. http(s) URL → 原样返回
2. data: base64 → 原样返回
3. /api/ave/assets/… → 还原成本地路径后转 base64(前端选择器给的格式)
4. 本地文件路径 → 转 base64
"""
s = str(path_or_url)
if s.startswith(("http://", "https://", "data:")):
return s
p = _url_to_local(s) or Path(s)
if not p.exists():
raise FileNotFoundError(
f"参考图不存在: {p}"
+ ("(未能从 /api/ave/assets/ 形式还原出本地文件)" if s.startswith("/api/") else ""))
b64 = base64.b64encode(p.read_bytes()).decode()
ext = p.suffix.lower().lstrip(".") or "png"
mime = {"jpg": "jpeg", "jpeg": "jpeg", "webp": "webp"}.get(ext, "png")
return f"data:image/{mime};base64,{b64}"
def build_keyframe(
prompt: str,
ref_images: list[str] | None = None,
*,
negative_prompt: str = "",
size: str = DEFAULT_SIZE,
n: int = 1,
model: str = DEFAULT_MODEL,
timeout: int = 180,
) -> list[str]:
"""生成关键帧,返回图片 URL 列表(1~n 张)
prompt 构图/光线/动作瞬间的描述(视觉描述,不含"运动"—— 运动属于视频阶段)
ref_images 参考图(本地路径或 URL),顺序即提示词里的"图1/图2…"
推荐:角色正面 + 角色 45° + 场景对应机位 [+ 道具]
"""
key = _api_key()
if not key:
raise RuntimeError("未找到 aliyun.api_key(agent-local/tools/ave/config/local.yaml)")
# ── 模型能力校验(依据 config/image_models.yaml)──────────
info = model_info(model)
if info.get("status"):
raise RuntimeError(
f"模型「{info.get('name', model)}」尚未接线(状态:{info['status']})。"
f"请先选百炼系模型,或告知我接入。")
if info and info.get("key_field", "aliyun") != "aliyun":
raise RuntimeError(
f"模型「{info.get('name', model)}」用的是 {info.get('key_field')} 凭证,"
f"当前 keyframe_gen 只实现了百炼通道。")
mx = (info.get("limits") or {}).get("max_input_images")
n_given = len(ref_images or [])
if mx and n_given > int(mx):
raise RuntimeError(
f"模型「{info.get('name', model)}」最多接受 {mx} 张输入图,"
f"当前给了 {n_given} 张 —— 请减少参考图,或换模型。")
n_max = (info.get("limits") or {}).get("n_max")
if n_max and int(n) > int(n_max):
raise RuntimeError(f"模型「{info.get('name', model)}」一次最多出 {n_max} 张(当前 {n})。")
full_text = prompt + (f"\n\n【避免】{negative_prompt}" if negative_prompt else "")
imgs = [{"image": _as_image_value(i)} for i in (ref_images or [])]
# 顺序按模型家族区分(见上方 IMAGE_FIRST_PREFIXES 的注释)
if str(model).startswith(IMAGE_FIRST_PREFIXES):
content: list[dict] = imgs + [{"text": full_text}]
else:
content = [{"text": full_text}] + imgs
payload = {
"model": model,
"input": {"messages": [{"role": "user", "content": content}]},
"parameters": {"size": size, "n": int(n), "watermark": False},
}
log.info("🖼 关键帧生成:model=%s refs=%d size=%s n=%d",
model, len(ref_images or []), size, n)
req = urllib.request.Request(API, data=json.dumps(payload).encode(),
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
method="POST")
with _NO_PROXY_OPENER.open(req, timeout=timeout) as r:
d = json.loads(r.read().decode())
out: list[str] = []
for ch in ((d.get("output") or {}).get("choices") or []):
for c in ((ch.get("message") or {}).get("content") or []):
if c.get("image"):
out.append(c["image"])
log.info("✅ 完成,产出 %d 张(usage=%s)", len(out),
json.dumps(d.get("usage") or {}, ensure_ascii=False))
return out
def download(url: str, dest: str | Path, timeout: int = 90) -> str:
dest = Path(dest)
dest.parent.mkdir(parents=True, exist_ok=True)
with _NO_PROXY_OPENER.open(urllib.request.Request(url, headers=UA), timeout=timeout) as r:
dest.write_bytes(r.read())
log.info("已保存: %s (%d KB)", dest, dest.stat().st_size // 1024)
return str(dest)
def main() -> int:
ap = argparse.ArgumentParser(description="关键帧生成(多图融合)")
ap.add_argument("--prompt", required=True, help="画面描述(视觉,不含运动)")
ap.add_argument("--role", action="append", default=[], help="角色参考图(可多次)")
ap.add_argument("--scene", action="append", default=[], help="场景参考图(可多次)")
ap.add_argument("--extra", action="append", default=[], help="其他参考图(道具等)")
ap.add_argument("--negative", default="", help="负向提示词")
ap.add_argument("--size", default=DEFAULT_SIZE,
help="画幅 宽*高(如 960*1696 竖屏 9:16 / 1280*720 横屏)")
ap.add_argument("--n", type=int, default=1)
ap.add_argument("--model", default=DEFAULT_MODEL)
ap.add_argument("--out", default="/tmp/keyframe.png")
ap.add_argument("--text-only", action="store_true", help="不用参考图(纯文生图)")
args = ap.parse_args()
refs = [] if args.text_only else (args.role + args.scene + args.extra)
urls = build_keyframe(args.prompt, refs, negative_prompt=args.negative,
size=args.size, n=args.n, model=args.model)
if not urls:
print("❌ 未返回图片")
return 1
for i, u in enumerate(urls):
dest = args.out if len(urls) == 1 else args.out.replace(".png", f"_v{i+1}.png")
download(u, dest)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""生成可灵任务对账表(供与账单核对)"""
import datetime
import json
import urllib.request
from pathlib import Path
import yaml
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
urllib.request.install_opener(opener)
CFG = Path.home() / "workbuddy-agent-os" / "agent-local" / "tools" / "ave" / "config" / "local.yaml"
AVE = Path.home() / "workbuddy-agent-os" / "agent-local" / "runtime" / "ave"
cfg = yaml.safe_load(CFG.read_text(encoding="utf-8"))
k = cfg["kling"]
hdr = {"Authorization": f"Bearer {k['api_key']}"}
base = k["base_url"]
with urllib.request.urlopen(
urllib.request.Request(base + "/v1/videos/text2video", headers=hdr), timeout=30) as r:
vids = json.loads(r.read().decode()).get("data") or []
local_files = []
for p in AVE.rglob("*.mp4"):
local_files.append((p.stat().st_mtime, str(p.relative_to(AVE))))
local_files.sort(reverse=True)
NOTES = {
"934535832286281787": "首次验证 kling-v3 可用",
"934536473163501607": "端到端流程 wf_5d5ffff0fc06 镜头1",
"934536909153013802": "端到端流程 wf_5d5ffff0fc06 镜头2",
"934537556619169795": "端到端流程 wf_5d5ffff0fc06 镜头3",
"934539639007219781": "口播线验证(oral_verify2)",
"934540864327012427": "并发生成测试 镜头1",
"934540864352174168": "并发生成测试 镜头2",
"934540864385880141": "并发生成测试 镜头3",
"934542570569990201": "流程测试",
"934612690390495243": "P2 编排通电验证",
"934618791945191489": "完整链路验证 镜头1",
"934618965245431828": "完整链路验证 镜头2",
}
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M")
lines = ["# 可灵任务对账表", "", f"生成时间:{now}", "",
"## 全部任务", "",
"| 时间 | 任务号 | 状态 | 时长 | 用途 |",
"|:--|:--|:--|:--|:--|"]
today_cnt = today_sec = 0
rows = sorted(vids, key=lambda t: -int(t.get("created_at") or 0))
for t in rows:
ms = int(t.get("created_at") or 0)
dts = datetime.datetime.fromtimestamp(ms / 1000).strftime("%Y-%m-%d %H:%M")
vs = (t.get("task_result") or {}).get("videos") or []
dur = float(vs[0].get("duration", 5) or 5) if vs else 5.0
note = NOTES.get(str(t.get("task_id")), "(历史任务)")
if dts.startswith("2026-10-01"):
today_cnt += 1
today_sec += dur
lines.append(f"| {dts} | `{t.get('task_id')}` | {t.get('task_status')} | {dur:.1f}s | {note} |")
total_sec = sum(
float(((t.get("task_result") or {}).get("videos") or [{}])[0].get("duration", 5) or 5)
for t in rows
)
lines += ["",
f"**今天(2026-10-01):{today_cnt} 条 / {today_sec:.1f} 秒**",
f"**全部:{len(rows)} 条 / {total_sec:.1f} 秒**", "",
"## 费用估算(按不同单价档)", "",
"| 单价档 | 今天 | 全部 |", "|:--|:--|:--|",
f"| ¥0.6/秒(无声 720P) | ¥{today_sec * 0.6:.1f} | ¥{total_sec * 0.6:.1f} |",
f"| ¥1.2/秒(有声 / 1080P) | ¥{today_sec * 1.2:.1f} | ¥{total_sec * 1.2:.1f} |",
f"| ¥2.4/秒(高配) | ¥{today_sec * 2.4:.1f} | ¥{total_sec * 2.4:.1f} |",
"",
"> ⚠️ 以上为**估算**。准确金额请以可灵控制台「消费明细」为准。",
"> 若单价与我估算不符,请把明细发我,我核对后调整代码里的计费参数。",
"",
"## 本地产物文件(最新 20 个)", ""]
for mt, rel in local_files[:20]:
dts = datetime.datetime.fromtimestamp(mt).strftime("%m-%d %H:%M")
lines.append(f"- `{dts}` {rel}")
out = AVE / "可灵任务对账表.md"
out.write_text("\n".join(lines), encoding="utf-8")
print(f"✅ 已生成: {out}")
print(f" 今天 {today_cnt} 条 / {today_sec:.1f} 秒 · 全部 {len(rows)} 条 / {total_sec:.1f} 秒")
@@ -0,0 +1,47 @@
"""
AVE 配置加载器
优先级: local.yaml > config.yaml > 默认值
"""
import os
from pathlib import Path
from typing import Any
import yaml
SYNC_ROOT = Path(__file__).resolve().parents[4] # agent-sync/
LOCAL_ROOT = SYNC_ROOT.parent / "agent-local" # agent-local/
def load_config() -> dict[str, Any]:
"""加载配置,local.yaml 覆盖 config.yaml 的对应字段"""
config: dict[str, Any] = {}
# 1. 模板配置
template_path = SYNC_ROOT / "05_tools" / "09_ave" / "config.yaml"
if template_path.exists():
with open(template_path) as f:
config.update(yaml.safe_load(f) or {})
# 2. 本地配置(覆盖)
local_path = LOCAL_ROOT / "tools" / "ave" / "config" / "local.yaml"
if local_path.exists():
with open(local_path) as f:
local = yaml.safe_load(f) or {}
_deep_merge(config, local)
return config
def _deep_merge(base: dict, override: dict) -> None:
"""递归合并字典"""
for k, v in override.items():
if k in base and isinstance(base[k], dict) and isinstance(v, dict):
_deep_merge(base[k], v)
else:
base[k] = v
def get_api_config(service: str) -> dict:
"""获取指定服务的 API 配置"""
cfg = load_config()
return cfg.get(service, {})
@@ -0,0 +1,213 @@
"""
AVE 成本追踪器
记录每次 API 调用的费用估算, 防止不知不觉扣费过多。
用法:
from lib.cost_tracker import CostTracker
tracker = CostTracker()
tracker.log("OmniHuman", duration=4.04, resolution="480P")
tracker.summary()
"""
import json
import os
from datetime import datetime
from pathlib import Path
# ── 已知价格 (元/秒) ───────────────────────────────────────
# 来源: 火山引擎即梦AI官方定价 (截至2026-05-09)
PRICING = {
# OmniHuman 1.5
"OmniHuman_480P": 0.5, # ¥0.5/秒
"OmniHuman_720P": 0.9, # ¥0.9/秒
# Wan2.2 (备用)
"Wan2.2_480P": 0.5,
"Wan2.2_720P": 0.9,
# DreamActor M1 (估算, 与OmniHuman同级)
"DreamActor_480P": 0.5,
"DreamActor_720P": 0.9,
# CosyVoice TTS (阿里云百炼, 按字符计费约 ¥0.002/字符)
"CosyVoice_per_char": 0.002,
# Pexels (免费)
"Pexels": 0,
# Kling LipSync (fal.ai, $0.014/5s ≈ ¥0.10/s)
"KlingLipSync_per_s": 0.10, # ¥0.10/秒 ≈ $0.014/5s
}
# ── 日志路径 ───────────────────────────────────────────────
LOG_DIR = Path(os.environ.get("AVE_LOCAL_DIR",
str(Path.home() / "workbuddy-agent-os/agent-local"))) / "tools" / "ave" / "logs"
LOG_DIR.mkdir(parents=True, exist_ok=True)
LOG_FILE = LOG_DIR / "cost_log.jsonl"
class CostTracker:
"""API 成本追踪器"""
def __init__(self):
self._session_log: list[dict] = []
def log(self, service: str, duration: float = 0, resolution: str = "480P",
chars: int = 0, status: str = "success", note: str = ""):
"""
记录一次 API 调用
参数:
service: API 服务名 (OmniHuman, DreamActor, CosyVoice, Pexels)
duration: 视频/音频时长(秒)
resolution: 分辨率 (480P/720P)
chars: CosyVoice 字符数
status: success/failed
note: 备注
"""
# 计算费用
if service == "CosyVoice":
unit_price = PRICING.get("CosyVoice_per_char", 0.002)
cost = round(chars * unit_price, 4)
elif service == "KlingLipSync":
unit_price = PRICING.get("KlingLipSync_per_s", 0.10)
cost = round(duration * unit_price, 4) if status == "success" else 0.0
elif service == "Pexels":
cost = 0.0
else:
key = f"{service}_{resolution}"
unit_price = PRICING.get(key, 0.5)
cost = round(duration * unit_price, 4) if status == "success" else 0.0
record = {
"time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"service": service,
"duration_s": duration,
"resolution": resolution,
"chars": chars,
"status": status,
"cost_yuan": cost,
"note": note,
}
self._session_log.append(record)
# 追加到日志文件
with open(LOG_FILE, "a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
# 同步写入 Dashboard DB (如果当前有 production)
if _current_production_id > 0:
try:
from lib.dashboard import log_cost as _dash_log_cost
_dash_log_cost(
production_id=_current_production_id,
service=service,
step_name=note.split(" ")[0] if " " in note else "",
duration_s=duration,
resolution=resolution,
chars=chars,
cost_yuan=cost,
status=status,
note=note,
)
except ImportError:
pass # dashboard 模块不可用时不报错
# 实时输出
if cost > 0:
print(f" 💰 {service}: {duration:.1f}s × ¥{unit_price}/s = ¥{cost:.2f}")
elif status == "failed":
print(f" 💰 {service}: 失败, 不计费")
else:
print(f" 💰 {service}: 免费")
return cost
def summary(self) -> dict:
"""输出本次会话的费用汇总"""
total = sum(r["cost_yuan"] for r in self._session_log)
success_calls = sum(1 for r in self._session_log if r["status"] == "success")
failed_calls = sum(1 for r in self._session_log if r["status"] == "failed")
print("\n" + "=" * 40)
print("💰 本次会话费用汇总")
print("=" * 40)
print(f" 成功调用: {success_calls} 次")
print(f" 失败调用: {failed_calls} 次")
print(f" 总费用: ¥{total:.2f}")
print(f" 账户余额: 未知 (请登录火山引擎控制台查看)")
print("=" * 40)
# 分服务统计
by_service = {}
for r in self._session_log:
s = r["service"]
if s not in by_service:
by_service[s] = {"count": 0, "total_s": 0, "cost": 0.0}
by_service[s]["count"] += 1
by_service[s]["total_s"] += r["duration_s"] if r["status"] == "success" else 0
by_service[s]["cost"] += r["cost_yuan"]
if by_service:
print("\n 按服务:")
for s, info in sorted(by_service.items()):
print(f" {s:15s} {info['count']}次, {info['total_s']:.1f}s, ¥{info['cost']:.2f}")
return {"total": total, "calls": success_calls + failed_calls}
def get_session_log(self) -> list[dict]:
return self._session_log
def _total_cost(self) -> float:
"""当前会话累计费用"""
return sum(r["cost_yuan"] for r in self._session_log)
# ── Dashboard 集成 ─────────────────────────────────────────
# 当前 production_id, 由 video_factory / main.py 设置
# CostTracker.log() 检测到该值 > 0 时自动写 dashboard DB
_current_production_id: int = 0
def set_current_production_id(pid: int):
"""设置当前 production ID, 后续 cost log 自动关联"""
global _current_production_id
_current_production_id = pid
def get_current_production_id() -> int:
return _current_production_id
# ── 全局单例 (在 main.py 中使用) ────────────────────────────
_tracker: CostTracker | None = None
def get_tracker() -> CostTracker:
global _tracker
if _tracker is None:
_tracker = CostTracker()
return _tracker
def print_history(days: int = 7):
"""打印最近 N 天的费用历史"""
if not LOG_FILE.exists():
print("暂无费用记录")
return
from datetime import timedelta
cutoff = datetime.now() - timedelta(days=days)
total = 0.0
count = 0
with open(LOG_FILE, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
r = json.loads(line)
t = datetime.strptime(r["time"], "%Y-%m-%d %H:%M:%S")
if t >= cutoff:
total += r["cost_yuan"]
count += 1
print(f" [{r['time']}] {r['service']:15s} {r['duration_s']:5.1f}s ¥{r['cost_yuan']:.2f} {r['note']}")
except (json.JSONDecodeError, KeyError, ValueError):
pass
print(f"\n 近 {days} 天: {count} 次调用, 总费用 ¥{total:.2f}")
@@ -0,0 +1,498 @@
from __future__ import annotations
"""
AVE dashboard — 生产监控数据层
职责:
SQLite schema 初始化 + 埋点封装
CLI 自动写 DB, FastAPI 后端读 DB
设计原则:
- 所有操作 try/except 包裹, 失败不影响 CLI 主线
- DB 路径: agent-local/tools/ave/data/ave.db
- 连接池: 每次调用创建新连接 (CLI 场景, 无并发问题)
用法:
from lib.dashboard import init_db, log_production, log_step, log_asset
init_db()
pid = log_production(strategy="oral", script_name="test", config={"key": "val"})
log_step(pid, "tts", "completed", cost=0.3, detail="85字")
log_step(pid, "compose", "completed", cost=0.0, detail="2 clips")
log_asset(pid, "generated_clip", "/tmp/out.mp4", tags=["hermit", "kling"])
"""
import json
import os
import sqlite3
import time
import uuid
from datetime import datetime
from pathlib import Path
from typing import Any
# ── DB 路径 ───────────────────────────────────────────────
LOCAL_ROOT = Path(os.environ.get("AVE_LOCAL_DIR",
str(Path.home() / "workbuddy-agent-os/agent-local")))
DB_DIR = LOCAL_ROOT / "tools" / "ave" / "data"
DB_DIR.mkdir(parents=True, exist_ok=True)
DB_PATH = DB_DIR / "ave.db"
# ── Schema ────────────────────────────────────────────────
# 从 PLANS/DASHBOARD_DESIGN.md 导出, 6 表
SCHEMA_SQL = """
CREATE TABLE IF NOT EXISTS productions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
strategy TEXT NOT NULL,
status TEXT DEFAULT 'running',
script_path TEXT,
script_name TEXT,
output_path TEXT,
duration_sec REAL,
total_cost REAL DEFAULT 0.0,
config_json TEXT,
error_message TEXT,
created_at TEXT DEFAULT (datetime('now','localtime')),
completed_at TEXT
);
CREATE TABLE IF NOT EXISTS production_steps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
production_id INTEGER NOT NULL REFERENCES productions(id),
step_name TEXT NOT NULL,
status TEXT DEFAULT 'pending',
started_at TEXT,
ended_at TEXT,
duration_sec REAL,
cost REAL DEFAULT 0.0,
detail TEXT,
error_message TEXT
);
CREATE TABLE IF NOT EXISTS assets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
asset_type TEXT NOT NULL,
name TEXT,
file_path TEXT,
file_size INTEGER,
hash TEXT,
source TEXT,
meta_json TEXT,
production_id INTEGER,
created_at TEXT DEFAULT (datetime('now','localtime'))
);
CREATE TABLE IF NOT EXISTS asset_tags (
asset_id INTEGER NOT NULL REFERENCES assets(id),
tag TEXT NOT NULL,
PRIMARY KEY (asset_id, tag)
);
CREATE TABLE IF NOT EXISTS production_assets (
production_id INTEGER NOT NULL REFERENCES productions(id),
asset_id INTEGER NOT NULL REFERENCES assets(id),
role TEXT,
PRIMARY KEY (production_id, asset_id)
);
CREATE TABLE IF NOT EXISTS cost_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
production_id INTEGER REFERENCES productions(id),
step_name TEXT,
service TEXT NOT NULL,
duration_s REAL DEFAULT 0,
resolution TEXT,
chars INTEGER DEFAULT 0,
cost_yuan REAL DEFAULT 0,
status TEXT DEFAULT 'success',
note TEXT,
logged_at TEXT DEFAULT (datetime('now','localtime'))
);
"""
# ═══════════════════════════════════════════════════════════
# 初始化
# ═══════════════════════════════════════════════════════════
def init_db() -> bool:
"""初始化 DB schema, 不存在则创建"""
try:
conn = sqlite3.connect(str(DB_PATH))
conn.executescript(SCHEMA_SQL)
conn.commit()
conn.close()
return True
except Exception as e:
print(f" [dashboard] DB 初始化失败: {e}")
return False
def _get_conn() -> sqlite3.Connection | None:
"""获取 DB 连接 (带 try/except)"""
try:
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
return conn
except Exception as e:
print(f" [dashboard] DB 连接失败: {e}")
return None
# ═══════════════════════════════════════════════════════════
# 埋点函数
# ═══════════════════════════════════════════════════════════
def log_production(
strategy: str,
script_path: str = "",
script_name: str = "",
config: dict | None = None,
) -> int:
"""
记录一条 production 开始
返回:
production_id (失败返回 -1)
"""
conn = _get_conn()
if not conn:
return -1
try:
cur = conn.execute(
"""INSERT INTO productions (strategy, status, script_path, script_name, config_json)
VALUES (?, 'running', ?, ?, ?)""",
(strategy, script_path, script_name, json.dumps(config or {}, ensure_ascii=False)),
)
conn.commit()
pid = cur.lastrowid
return pid
except Exception as e:
print(f" [dashboard] log_production 失败: {e}")
return -1
finally:
conn.close()
def complete_production(
production_id: int,
status: str = "completed",
output_path: str = "",
duration_sec: float = 0.0,
total_cost: float = 0.0,
error_message: str = "",
):
"""标记 production 完成"""
conn = _get_conn()
if not conn:
return
try:
conn.execute(
"""UPDATE productions SET status=?, output_path=?, duration_sec=?,
total_cost=?, error_message=?, completed_at=datetime('now','localtime')
WHERE id=?""",
(status, output_path, duration_sec, total_cost, error_message, production_id),
)
conn.commit()
except Exception as e:
print(f" [dashboard] complete_production 失败: {e}")
finally:
conn.close()
def log_step(
production_id: int,
step_name: str,
status: str = "completed",
cost: float = 0.0,
detail: str = "",
error_message: str = "",
) -> bool:
"""
记录一步生产步骤
参数:
production_id: 所属 production ID
step_name: 步骤名 (tts, search_material, compose, bgm, lipsync, ...)
status: completed / failed
cost: 该步骤费用
detail: 文字详情
"""
conn = _get_conn()
if not conn:
return False
try:
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
conn.execute(
"""INSERT INTO production_steps
(production_id, step_name, status, started_at, ended_at, cost, detail, error_message)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(production_id, step_name, status, now, now, cost, detail, error_message),
)
conn.commit()
return True
except Exception as e:
print(f" [dashboard] log_step 失败: {e}")
return False
finally:
conn.close()
def log_asset(
production_id: int,
asset_type: str,
file_path: str = "",
name: str = "",
source: str = "",
tags: list[str] | None = None,
meta: dict | None = None,
) -> int:
"""
记录一个生成/使用的资产
返回:
asset_id (失败返回 -1)
"""
conn = _get_conn()
if not conn:
return -1
try:
file_size = os.path.getsize(file_path) if file_path and os.path.exists(file_path) else 0
cur = conn.execute(
"""INSERT INTO assets (asset_type, name, file_path, file_size, source, meta_json, production_id)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(asset_type, name, file_path, file_size, source,
json.dumps(meta or {}, ensure_ascii=False), production_id),
)
aid = cur.lastrowid
# 关联 production ↔ asset
conn.execute(
"INSERT OR IGNORE INTO production_assets (production_id, asset_id, role) VALUES (?, ?, ?)",
(production_id, aid, "output" if source == "generated" else "input"),
)
# 标签
if tags:
for tag in tags:
conn.execute(
"INSERT OR IGNORE INTO asset_tags (asset_id, tag) VALUES (?, ?)",
(aid, tag.strip().lower()),
)
conn.commit()
return aid
except Exception as e:
print(f" [dashboard] log_asset 失败: {e}")
return -1
finally:
conn.close()
def log_cost(
production_id: int,
service: str,
step_name: str = "",
duration_s: float = 0.0,
resolution: str = "",
chars: int = 0,
cost_yuan: float = 0.0,
status: str = "success",
note: str = "",
):
"""记录费用明细"""
conn = _get_conn()
if not conn:
return
try:
conn.execute(
"""INSERT INTO cost_log
(production_id, step_name, service, duration_s, resolution, chars, cost_yuan, status, note)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(production_id, step_name, service, duration_s, resolution, chars, cost_yuan, status, note),
)
conn.commit()
# 更新 production 累计费用
conn.execute(
"UPDATE productions SET total_cost = total_cost + ? WHERE id = ?",
(cost_yuan, production_id),
)
conn.commit()
except Exception as e:
print(f" [dashboard] log_cost 失败: {e}")
finally:
conn.close()
# ═══════════════════════════════════════════════════════════
# 查询 API (供 FastAPI 后端使用)
# ═══════════════════════════════════════════════════════════
def get_productions(
limit: int = 50,
offset: int = 0,
strategy: str | None = None,
status: str | None = None,
) -> list[dict]:
"""查询 production 列表"""
conn = _get_conn()
if not conn:
return []
try:
where = []
params = []
if strategy:
where.append("strategy = ?")
params.append(strategy)
if status:
where.append("status = ?")
params.append(status)
where_clause = (" WHERE " + " AND ".join(where)) if where else ""
rows = conn.execute(
f"SELECT * FROM productions{where_clause} ORDER BY id DESC LIMIT ? OFFSET ?",
params + [limit, offset],
).fetchall()
return [dict(r) for r in rows]
except Exception as e:
print(f" [dashboard] get_productions 失败: {e}")
return []
finally:
conn.close()
def get_production_detail(production_id: int) -> dict | None:
"""查询单条 production + steps + assets + costs"""
conn = _get_conn()
if not conn:
return None
try:
prod = conn.execute(
"SELECT * FROM productions WHERE id = ?", (production_id,)
).fetchone()
if not prod:
return None
steps = conn.execute(
"SELECT * FROM production_steps WHERE production_id = ? ORDER BY id",
(production_id,),
).fetchall()
assets = conn.execute(
"""SELECT a.*, GROUP_CONCAT(at.tag, ',') as tags
FROM assets a
LEFT JOIN asset_tags at ON at.asset_id = a.id
WHERE a.production_id = ?
GROUP BY a.id ORDER BY a.id""",
(production_id,),
).fetchall()
costs = conn.execute(
"SELECT * FROM cost_log WHERE production_id = ? ORDER BY id",
(production_id,),
).fetchall()
return {
"production": dict(prod),
"steps": [dict(s) for s in steps],
"assets": [dict(a) for a in assets],
"costs": [dict(c) for c in costs],
}
except Exception as e:
print(f" [dashboard] get_production_detail 失败: {e}")
return None
finally:
conn.close()
def get_assets(asset_type: str | None = None, tag: str | None = None,
limit: int = 100, offset: int = 0) -> list[dict]:
"""查询资产列表"""
conn = _get_conn()
if not conn:
return []
try:
where = []
params = []
if asset_type:
where.append("a.asset_type = ?")
params.append(asset_type)
if tag:
where.append("EXISTS (SELECT 1 FROM asset_tags WHERE asset_id = a.id AND tag = ?)")
params.append(tag.lower())
where_clause = (" WHERE " + " AND ".join(where)) if where else ""
rows = conn.execute(
f"""SELECT a.*, GROUP_CONCAT(at.tag, ',') as tags
FROM assets a
LEFT JOIN asset_tags at ON at.asset_id = a.id
{where_clause}
GROUP BY a.id
ORDER BY a.id DESC LIMIT ? OFFSET ?""",
params + [limit, offset],
).fetchall()
return [dict(r) for r in rows]
except Exception as e:
print(f" [dashboard] get_assets 失败: {e}")
return []
finally:
conn.close()
def get_summary() -> dict:
"""获取总览统计"""
conn = _get_conn()
if not conn:
return {}
try:
total_prods = conn.execute("SELECT COUNT(*) FROM productions").fetchone()[0]
completed = conn.execute("SELECT COUNT(*) FROM productions WHERE status='completed'").fetchone()[0]
failed = conn.execute("SELECT COUNT(*) FROM productions WHERE status='failed'").fetchone()[0]
total_cost = conn.execute("SELECT COALESCE(SUM(total_cost), 0) FROM productions").fetchone()[0]
total_assets = conn.execute("SELECT COUNT(*) FROM assets").fetchone()[0]
# 本月
month_start = datetime.now().strftime("%Y-%m-01")
month_prods = conn.execute(
"SELECT COUNT(*) FROM productions WHERE created_at >= ?", (month_start,)
).fetchone()[0]
month_cost = conn.execute(
"SELECT COALESCE(SUM(total_cost), 0) FROM productions WHERE created_at >= ?",
(month_start,),
).fetchone()[0]
return {
"total_productions": total_prods,
"completed": completed,
"failed": failed,
"total_cost": round(total_cost, 2),
"total_assets": total_assets,
"month_productions": month_prods,
"month_cost": round(month_cost, 2),
}
except Exception as e:
print(f" [dashboard] get_summary 失败: {e}")
return {}
finally:
conn.close()
def get_cost_breakdown() -> list[dict]:
"""按策略统计费用"""
conn = _get_conn()
if not conn:
return []
try:
rows = conn.execute(
"""SELECT strategy,
COUNT(*) as count,
COALESCE(SUM(duration_sec), 0) as total_duration,
COALESCE(SUM(total_cost), 0) as total_cost,
ROUND(AVG(total_cost), 2) as avg_cost
FROM productions
GROUP BY strategy
ORDER BY total_cost DESC"""
).fetchall()
return [dict(r) for r in rows]
except Exception:
return []
finally:
conn.close()
@@ -0,0 +1,131 @@
"""
lib.ffmpeg — FFmpeg / FFprobe 路径统一入口
=============================================
本机 FFmpeg 装在 `~/.local/bin`(非 brew、不在默认 PATH),
所有模块统一从这里取路径,避免 `subprocess.run(["ffmpeg", ...])` 找不到命令。
用法:
from lib.ffmpeg import FFMPEG, FFPROBE
subprocess.run([FFMPEG, "-y", "-i", src, dst])
"""
from __future__ import annotations
import os
import shutil
from pathlib import Path
_CANDIDATES = [
os.path.expanduser("~/.local/bin/ffmpeg"),
"/usr/local/bin/ffmpeg",
"/opt/homebrew/bin/ffmpeg",
]
_CANDIDATES_PROBE = [
os.path.expanduser("~/.local/bin/ffprobe-real"),
"/usr/local/bin/ffprobe",
"/opt/homebrew/bin/ffprobe",
]
def _find_real_ffprobe() -> str | None:
"""查找真正可用的 ffprobe
⚠️ 本机 `~/.local/bin/ffprobe` 实为 `ffmpeg` 的**符号链接**(不支持 -show_entries);
真正的 ffprobe 在别处(如 Trae/火星代码自带 6.1.1 完整版)。
"""
import glob
pats = [
os.path.expanduser("~/.marscode/**/modules/ai-agent/ffprobe"),
os.path.expanduser("~/.trae/**/ffprobe"),
os.path.expanduser("~/.vscode/**/ffprobe"),
"/Applications/*.app/Contents/**/ffprobe",
]
for pat in pats:
for p in sorted(glob.glob(pat, recursive=True), reverse=True):
try:
if os.path.isfile(p) and os.access(p, os.X_OK):
# 验证确实支持 -show_entries(真正的 ffprobe)
import subprocess
r = subprocess.run([p, "-h"], capture_output=True, text=True, timeout=10)
if "show_entries" in ((r.stdout or "") + (r.stderr or "")):
return p
except Exception:
continue
return None
# ═══════════════════════════════════════════════════════════
# 媒体信息探测(**用 `ffmpeg -i` 解析**)
#
# ⚠️ 本机 ffprobe 是精简构建:不支持 `-show_entries` / `-select_streams`
# (会报 `Unrecognized option`)→ 相对地,`ffmpeg -i` 完全可用。
# 因此统一改用 ffmpeg 输出解析,避免各处 ffprobe 调用**静默失败**。
# ═══════════════════════════════════════════════════════════
def probe_info(media_path) -> dict:
"""探测媒体信息 → {duration, bitrate_kbps, size_bytes, has_audio, has_video}"""
import re
import subprocess
p = Path(media_path)
info = {"duration": 0.0, "bitrate_kbps": 0,
"size_bytes": p.stat().st_size if p.exists() else 0,
"has_audio": False, "has_video": False}
if not p.exists():
return info
try:
r = subprocess.run([FFMPEG, "-i", str(p)], capture_output=True, text=True, timeout=30)
txt = (r.stderr or "") + (r.stdout or "")
except Exception:
return info
m = re.search(r"Duration:\s*(\d+):(\d+):([\d.]+)", txt)
if m:
info["duration"] = int(m.group(1)) * 3600 + int(m.group(2)) * 60 + float(m.group(3))
m = re.search(r"bitrate:\s*(\d+)\s*kb/s", txt)
if m:
info["bitrate_kbps"] = int(m.group(1))
elif info["duration"] > 0:
info["bitrate_kbps"] = int(info["size_bytes"] * 8 / info["duration"] / 1000)
info["has_audio"] = "Audio:" in txt
info["has_video"] = "Video:" in txt
# 视频尺寸(从 "Video: h264 ..., 1080x1920" 解析)
m = re.search(r"Video:.*?(\d{2,5})x(\d{2,5})", txt)
if m:
info["width"], info["height"] = int(m.group(1)), int(m.group(2))
else:
info["width"], info["height"] = 0, 0
return info
def probe_duration(media_path) -> float:
"""媒体时长(秒)—— 兼容实现(ffmpeg -i 解析)"""
return probe_info(media_path)["duration"]
def has_audio_stream(media_path) -> bool:
"""是否含音轨 —— 兼容实现"""
return probe_info(media_path)["has_audio"]
def _resolve(candidates: list[str], name: str) -> str:
for c in candidates:
if Path(c).exists():
return c
found = shutil.which(name)
if found:
return found
# 找不到也返回名字(让调用时报清晰的错),但记录警告
return name
FFMPEG = _resolve(_CANDIDATES, "ffmpeg")
# ⚠️ 本机 `~/.local/bin/ffprobe` 实为 ffmpeg 的**符号链接**(不支持 -show_entries)
# → 优先查找**真正的** ffprobe;找不到就退回 FFMPEG(配合 probe_info 使用)
FFPROBE = _find_real_ffprobe() or FFMPEG
__all__ = ["FFMPEG", "FFPROBE", "ffmpeg_available"]
def ffmpeg_available() -> bool:
"""ffmpeg 是否真的可用"""
return Path(FFMPEG).exists() or bool(shutil.which("ffmpeg"))
@@ -0,0 +1,135 @@
#!/usr/bin/env python3
"""
AVE 文件上传助手 — 上传文件到 ghvideo Gitee 仓库获取公网直链
用途:
即梦/火山引擎 API 需要图片/视频/音频的公网 HTTP URL。
通过 Gitee raw 服务提供直链: https://gitee.com/babycalf/ghvideo/raw/main/{filename}
用法:
python ghvideo_upload.py /path/to/file.jpg
python ghvideo_upload.py --dir /path/to/dir/ # 批量上传目录下所有文件
返回:
https://gitee.com/babycalf/ghvideo/raw/main/filename.ext
"""
import argparse
import os
import subprocess
import sys
from pathlib import Path
REPO_DIR = Path.home() / "workbuddy-agent-os" / "agent-local" / "tools" / "ave" / "ghvideo"
REPO_URL = "https://gitee.com/babycalf/ghvideo.git"
RAW_BASE = "https://gitee.com/babycalf/ghvideo/raw/main"
def main():
parser = argparse.ArgumentParser(description="上传文件到 ghvideo 中转仓库")
parser.add_argument("path", nargs="*", help="文件或目录路径")
parser.add_argument("--dir", help="批量上传目录")
args = parser.parse_args()
files = []
if args.dir:
for f in sorted(Path(args.dir).iterdir()):
if f.is_file():
files.append(str(f))
elif args.path:
for p in args.path:
if os.path.isdir(p):
for f in sorted(Path(p).iterdir()):
if f.is_file():
files.append(str(f))
else:
files.append(p)
else:
parser.print_help()
return
if not files:
print("没有文件")
return
# 确保仓库存在
if not (REPO_DIR / ".git").exists():
print(f"初始化仓库: {REPO_DIR}")
subprocess.run(["git", "init"], cwd=REPO_DIR, capture_output=True)
subprocess.run(["git", "remote", "add", "origin", REPO_URL], cwd=REPO_DIR, capture_output=True)
# 复制文件到仓库目录
for f in files:
src = Path(f)
if not src.exists():
print(f" 跳过: {f} (不存在)")
continue
dest = REPO_DIR / src.name
import shutil
shutil.copy2(str(src), str(dest))
print(f" 复制: {src.name}")
# Git 提交推送
subprocess.run(["git", "add", "-A"], cwd=REPO_DIR, capture_output=True)
subprocess.run(["git", "commit", "-m", f"upload: {len(files)} files"], cwd=REPO_DIR, capture_output=True)
r = subprocess.run(["git", "push", "-u", "origin", "master"], cwd=REPO_DIR,
capture_output=True, text=True, timeout=60)
if r.returncode == 0:
print(f"\n✅ 推送成功! {len(files)} 个文件")
for f in files:
name = Path(f).name
url = f"{RAW_BASE}/{name}"
print(f" {url}")
else:
# 可能已经有内容, 尝试 pull 再 push
print(f" 推送失败, 尝试 pull...")
subprocess.run(["git", "pull", "--rebase", "origin", "master"], cwd=REPO_DIR,
capture_output=True, timeout=30)
r2 = subprocess.run(["git", "push", "-u", "origin", "master"], cwd=REPO_DIR,
capture_output=True, text=True, timeout=60)
if r2.returncode == 0:
print(f"✅ 推送成功!")
for f in files:
name = Path(f).name
url = f"{RAW_BASE}/{name}"
print(f" {url}")
else:
print(f"❌ 推送失败: {r2.stderr[:200]}")
def upload_and_get_url(file_path: str) -> str:
"""
上传文件到 ghvideo 仓库并返回公网直链
参数:
file_path: 本地文件路径
返回:
公网直链 URL
"""
import shutil
name = Path(file_path).name
dest = REPO_DIR / name
# 复制文件到仓库
shutil.copy2(file_path, str(dest))
# 提交推送
subprocess.run(["git", "-C", str(REPO_DIR), "add", name], capture_output=True)
subprocess.run(["git", "-C", str(REPO_DIR), "commit", "-m", f"upload {name}"],
capture_output=True)
r = subprocess.run(["git", "-C", str(REPO_DIR), "push", "origin", "master"],
capture_output=True, text=True, timeout=60)
if r.returncode != 0:
# pull 再试
subprocess.run(["git", "-C", str(REPO_DIR), "pull", "--rebase", "origin", "master"],
capture_output=True, timeout=30)
subprocess.run(["git", "-C", str(REPO_DIR), "push", "origin", "master"],
capture_output=True, timeout=60)
url = f"{RAW_BASE}/{name}"
print(f" 上传完成: {url}")
return url
if __name__ == "__main__":
main()
@@ -0,0 +1,93 @@
"""
lib/http.py — 统一 HTTP 入口(禁用系统代理 + 自动重试)
## 为什么需要(2026-10-04 定位的坑)
macOS 开启系统代理(如 127.0.0.1:7892)后,Python 的 `urllib.request.urlopen`
会**自动读取系统代理设置**。而系统代理对国内 API(可灵 / 百炼 / 火山)经常
时通时不通,表现为:
[SSL: UNEXPECTED_EOF_WHILE_READING] EOF occurred in violation of protocol
→ 用户看到的是「生成随机失败」「分镜生成失败」这类难以复现的问题。
实测:直连(禁代理)3/3 次 HTTP 200、0.2~0.3 秒;走代理则间歇性 SSL 错误。
## 用法
from lib.http import urlopen, request_json
req = urllib.request.Request(url, headers={...})
with urlopen(req, timeout=60) as r: # 已禁代理 + 自动重试
data = r.read()
# 或一步到位:
data = request_json(url, method="POST", body={...}, headers={...})
"""
from __future__ import annotations
import json
import time
import urllib.error
import urllib.request
from typing import Any, Optional
# 禁代理 opener(进程内复用)
_NO_PROXY_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))
DEFAULT_RETRIES = 3
DEFAULT_BACKOFF = 1.5 # 秒;第 n 次失败后等待 backoff * n
# 值得重试的 HTTP 状态码:
# · 429 限流(等一会儿可能就通)
# · 5xx 服务端临时故障(可灵/百炼偶尔返回 502/503)
# 不重试:其余 4xx(参数错/鉴权失败/余额不足)—— 重试只是浪费时间
_RETRYABLE_HTTP = frozenset({429, 500, 502, 503, 504})
def urlopen(req, timeout: int = 60, retries: int = DEFAULT_RETRIES):
"""禁用代理地发起请求,失败自动重试(指数退避)
返回的是可作上下文管理器使用的 response 对象。
重试覆盖:URLError / SSLError / OSError / 超时 / 连接中断。
⚠️ HTTP 4xx/5xx 不重试(那是业务错误,重试无意义)——
本函数只对**网络层异常**重试。
"""
last_exc: Optional[BaseException] = None
for attempt in range(max(1, retries)):
try:
return _NO_PROXY_OPENER.open(req, timeout=timeout)
except urllib.error.HTTPError as e:
# 5xx(服务端临时故障)与 429(限流)值得重试;
# 其余 4xx(参数错/鉴权失败/余额不足)重试无意义 → 直接抛
if e.code in _RETRYABLE_HTTP:
last_exc = e
if attempt < retries - 1:
time.sleep(DEFAULT_BACKOFF * (attempt + 1))
continue
raise
except Exception as e:
last_exc = e
if attempt < retries - 1:
time.sleep(DEFAULT_BACKOFF * (attempt + 1))
raise last_exc # type: ignore[misc]
def request_json(url: str, method: str = "GET", body: Any = None,
headers: Optional[dict] = None, timeout: int = 60,
retries: int = DEFAULT_RETRIES) -> dict:
"""发起 JSON 请求并解析响应(禁用代理 + 自动重试)
· body 非 None 时自动 JSON 序列化并加 Content-Type
· 返回值:解析后的 dict(解析失败返回 {})
"""
hdr = dict(headers or {})
data = None
if body is not None:
data = json.dumps(body).encode("utf-8")
hdr.setdefault("Content-Type", "application/json")
req = urllib.request.Request(url, data=data, headers=hdr, method=method)
with urlopen(req, timeout=timeout, retries=retries) as r:
raw = r.read()
try:
return json.loads(raw.decode("utf-8"))
except Exception:
return {}

Some files were not shown because too many files have changed in this diff Show More