Commit 17e6455eeb4d9d53fd707a706b8f01d9becf597b
1 parent
5261d0d2
docs: add create-drama documentation and scripts
Showing
5 changed files
with
1124 additions
and
0 deletions
docs/create-drama/.env
0 → 100644
| 1 | +# === MySQL 数据库 === | |
| 2 | +DB_HOST=rm-2zeel4sa31na88gd4qo.rwlb.rds.aliyuncs.com | |
| 3 | +DB_PORT=3306 | |
| 4 | +DB_USER=lemon | |
| 5 | +DB_PASSWORD=Lemon@123456 | |
| 6 | +DB_NAME=shadiao_drama | |
| 7 | + | |
| 8 | +# === 阿里云 OSS(图片上传) === | |
| 9 | +OSS_REGION=oss-cn-beijing | |
| 10 | +OSS_ACCESS_KEY_ID=LTAI5tGtipK3GrbTwZm7JjTS | |
| 11 | +OSS_ACCESS_KEY_SECRET=r6axVSGVyIzL3Ty2T5pV1SVFeiir3t | |
| 12 | +OSS_BUCKET=shadiao-drama | |
| 13 | +OSS_ENDPOINT=https://oss-cn-beijing.aliyuncs.com | |
| 14 | + | |
| 15 | +# === 万界生图 API(Jimeng + Nano Banana Pro) === | |
| 16 | +WANJIE_API_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3NzA5NjIyNzgsImtleSI6IjVLNzVaOFROQzlGNEhNMzdQOVk3In0.2axgWHccUPjL3cQr2-1jAGLZ4hVAcTbn-LU78CMMex4 | |
| 17 | +USER_ID=1 | |
| 0 | 18 | \ No newline at end of file | ... | ... |
docs/create-drama/SKILL.md
0 → 100644
| 1 | +--- | |
| 2 | +description: 从素材(新闻、故事、段子等)自动生成 classic 互动剧本(含角色图、场景图)并写入数据库 | |
| 3 | +--- | |
| 4 | + | |
| 5 | +# create-drama — 从素材自动生成完整 classic 互动剧本 | |
| 6 | + | |
| 7 | +根据用户提供的内容素材(新闻、故事、段子、历史事件等),生成一个完整的 classic 类型互动剧本,包括: | |
| 8 | +- 剧本数据(角色、台词、表情、动作) | |
| 9 | +- 角色立绘图(AI 生图 → 去背景 → 上传 OSS) | |
| 10 | +- 背景图和封面图(AI 生图 → 上传 OSS) | |
| 11 | +- 写入数据库 | |
| 12 | + | |
| 13 | +## 输入 | |
| 14 | + | |
| 15 | +用户会在 `$ARGUMENTS` 中提供内容素材。素材可能是: | |
| 16 | +- 一段新闻报道 | |
| 17 | +- 一个故事/段子 | |
| 18 | +- 一个历史事件描述 | |
| 19 | +- 一个创意想法 | |
| 20 | +- 一个文件路径(需要先读取文件内容) | |
| 21 | + | |
| 22 | +## 执行步骤 | |
| 23 | + | |
| 24 | +### 第 1 步:分析素材 | |
| 25 | + | |
| 26 | +仔细阅读用户提供的素材内容。如果是文件路径,先用 Read 工具读取文件。 | |
| 27 | + | |
| 28 | +### 第 2 步:生成完整剧本 JSON | |
| 29 | + | |
| 30 | +根据素材内容,你自己(Claude)直接生成剧本数据 JSON。**不要调用任何外部 LLM API。** | |
| 31 | + | |
| 32 | +生成的 JSON 必须严格遵循以下格式: | |
| 33 | + | |
| 34 | +```json | |
| 35 | +{ | |
| 36 | + "name": "剧本名称(简短,2-6个字)", | |
| 37 | + "title": "显示标题(可以带 emoji,稍微长一点)", | |
| 38 | + "subtitle": "— 副标题 —", | |
| 39 | + "description": "剧本简介(一句话概括)", | |
| 40 | + "systemPrompt": "用于后续 AI 续写的系统提示词(详见下方规则)", | |
| 41 | + "isPublic": false, | |
| 42 | + "userId": 1, | |
| 43 | + "tags": "标签1,标签2,标签3", | |
| 44 | + "characters": [ | |
| 45 | + { | |
| 46 | + "name": "角色名", | |
| 47 | + "gender": "male 或 female", | |
| 48 | + "voiceId": "从音色列表选择", | |
| 49 | + "imagePrompt": "角色立绘生图 prompt(详见下方规则)" | |
| 50 | + } | |
| 51 | + ], | |
| 52 | + "script": [ | |
| 53 | + { | |
| 54 | + "character": "角色名(必须在 characters 中定义过)", | |
| 55 | + "dialogue": "台词内容", | |
| 56 | + "avatar": "表情文件名", | |
| 57 | + "action": "动画效果", | |
| 58 | + "position": "left 或 right" | |
| 59 | + } | |
| 60 | + ], | |
| 61 | + "backgroundPrompt": "背景图生图 prompt", | |
| 62 | + "coverPrompt": "封面图生图 prompt" | |
| 63 | +} | |
| 64 | +``` | |
| 65 | + | |
| 66 | +### 生成规则 | |
| 67 | + | |
| 68 | +#### 角色 (characters) | |
| 69 | + | |
| 70 | +- 数量:2-5 个 | |
| 71 | +- 每个角色必须有 name、gender、voiceId、imagePrompt | |
| 72 | +- voiceId 选择:参考 [reference.md](reference.md) 中的完整音色列表 | |
| 73 | + - **优先使用多情感音色**(支持 happy/sad/angry 等情绪切换,效果更好) | |
| 74 | + - 根据角色性格、年龄、性别匹配合适的音色 | |
| 75 | + - 英文角色使用英文多情感音色(Candice, Serena, Glen, Sylus, Corey, Nadia) | |
| 76 | + - 搞笑角色可用 IP 仿音(唐僧、猪八戒等)或趣味口音 | |
| 77 | + - 常用推荐: | |
| 78 | + - 男主角: `zh_male_yangguangqingnian_emo_v2_mars_bigtts`(阳光青年)或 `zh_male_junlangnanyou_emo_v2_mars_bigtts`(俊朗男友) | |
| 79 | + - 女主角: `zh_female_roumeinvyou_emo_v2_mars_bigtts`(柔美女友)或 `zh_female_gaolengyujie_emo_v2_mars_bigtts`(高冷御姐) | |
| 80 | + - 反派/严肃: `zh_male_lengkugege_emo_v2_mars_bigtts`(冷酷哥哥)或 `zh_male_aojiaobazong_emo_v2_mars_bigtts`(傲娇霸总) | |
| 81 | + - 搞笑: `ICL_zh_male_youmodaye_tob`(幽默大爷)或 `zh_male_jingqiangkanye_emo_mars_bigtts`(京腔侃爷) | |
| 82 | + - 老年: `zh_female_popo_mars_bigtts`(婆婆)或 `ICL_zh_male_youmodaye_tob`(幽默大爷) | |
| 83 | + - 英文男: `en_male_sylus_emo_v2_mars_bigtts`(Sylus)或 `en_male_glen_emo_v2_mars_bigtts`(Glen) | |
| 84 | + - 英文女: `en_female_candice_emo_v2_mars_bigtts`(Candice)或 `en_female_skye_emo_v2_mars_bigtts`(Serena) | |
| 85 | + | |
| 86 | +#### 角色立绘 imagePrompt | |
| 87 | + | |
| 88 | +每个角色必须生成 imagePrompt,遵循以下模板: | |
| 89 | + | |
| 90 | +``` | |
| 91 | +生成一张**[人物名字或身份]**的全身站立画像。 | |
| 92 | +核心风格:沙雕动漫风格(meme style),2D简笔画,线条清晰粗犷,色彩平涂,带有幽默滑稽感,略微的大头小身体比例。 | |
| 93 | +脸部要求(最重要):脸部必须是纯肉色的完全空白,绝对不要画眼睛、鼻子、嘴巴或眉毛等任何五官,保留一个干净完整的面部轮廓,方便后期添加表情包。 | |
| 94 | +服装与动作:角色穿着**[根据角色描述填写服装特征],手里拿着[根据角色身份推断合适的道具],[一个有趣的站姿或动作]**。 | |
| 95 | +背景要求:纯色干净背景(如浅蓝灰色),不要任何环境元素或杂物,边缘清晰,方便直接抠图。 | |
| 96 | +``` | |
| 97 | + | |
| 98 | +根据角色具体描述,在 [] 处填入合适内容。 | |
| 99 | + | |
| 100 | +#### 背景图 backgroundPrompt | |
| 101 | + | |
| 102 | +``` | |
| 103 | +横版宽屏场景背景图,卡通风格,适合互动剧背景。场景:[根据剧本内容描述场景]。不要人物,色彩鲜明,气氛感强。 | |
| 104 | +``` | |
| 105 | + | |
| 106 | +#### 封面图 coverPrompt | |
| 107 | + | |
| 108 | +``` | |
| 109 | +剧目封面海报。标题:[剧本标题]。展示[角色名]等角色的剪影轮廓,色彩鲜明,有标题感,卡通风格。 | |
| 110 | +``` | |
| 111 | + | |
| 112 | +#### 台词 (script) | |
| 113 | + | |
| 114 | +- 数量:10-20 句(根据素材丰富度调整,尽量多一些,让剧情完整) | |
| 115 | +- avatar 可选值:`normal.png`, `happy.png`, `very_happy.png`, `angry.png`, `sad.png`, `cry.png`, `shock.png`, `smirk.png`, `speechless.png` | |
| 116 | +- action 可选值: | |
| 117 | + - `none` — 默认,无动画 | |
| 118 | + - `shake` — 震动,适合愤怒/惊吓/强调 | |
| 119 | + - `jump` — 跳跃,适合惊喜/激动 | |
| 120 | + - `spin` — 旋转,适合混乱/搞笑 | |
| 121 | + - `zoom-in` — 放大,适合重要台词/特写 | |
| 122 | +- position: 每个角色应该有固定站位(left/right),2个角色时一左一右,3个以上轮替 | |
| 123 | +- 台词要生动有趣,符合互动剧的沙雕风格 | |
| 124 | +- 对话要有戏剧冲突和反转 | |
| 125 | +- 每句台词不要太长,控制在 30 字以内 | |
| 126 | + | |
| 127 | +#### systemPrompt | |
| 128 | + | |
| 129 | +- 要详细描述世界观和角色设定 | |
| 130 | +- 包含语言风格指导(用于后续 AI 续写对话) | |
| 131 | +- 至少 200 字 | |
| 132 | + | |
| 133 | +#### tags | |
| 134 | + | |
| 135 | +- 1-3 个标签,逗号分隔 | |
| 136 | +- 如:`搞笑,职场,反转` | |
| 137 | + | |
| 138 | +### 第 3 步:写入临时 JSON 文件 | |
| 139 | + | |
| 140 | +将生成的 JSON 写入临时文件 `/tmp/drama_<timestamp>.json` | |
| 141 | + | |
| 142 | +### 第 4 步:生成图片并上传 OSS | |
| 143 | + | |
| 144 | +找到本 skill 的目录路径(即本文件 SKILL.md 所在目录),执行图片生成脚本: | |
| 145 | + | |
| 146 | +```bash | |
| 147 | +node <skill目录>/generate-images.js /tmp/drama_<timestamp>.json | |
| 148 | +``` | |
| 149 | + | |
| 150 | +该脚本会: | |
| 151 | +1. 为每个角色生成立绘图(Jimeng → fallback Nano Banana Pro) | |
| 152 | +2. 自动去背景(rembg) | |
| 153 | +3. 为剧本生成背景图和封面图 | |
| 154 | +4. 全部上传到阿里云 OSS | |
| 155 | +5. 将 URL 写回 JSON 文件(更新 characters[].bodyUrl, coverUrl, backgroundUrl) | |
| 156 | + | |
| 157 | +**注意**:该脚本需要 `ali-oss`, `sharp` npm 包以及 `rembg` Python 工具。如果 rembg 未安装,会跳过去背景步骤。 | |
| 158 | + | |
| 159 | +### 第 5 步:写入数据库 | |
| 160 | + | |
| 161 | +执行入库脚本: | |
| 162 | + | |
| 163 | +```bash | |
| 164 | +node <skill目录>/insert-drama.js /tmp/drama_<timestamp>.json | |
| 165 | +``` | |
| 166 | + | |
| 167 | +脚本会在事务中创建: | |
| 168 | +- `dramas` 表记录(剧本元数据) | |
| 169 | +- `drama_characters` 表记录(角色配置 + 身体图 URL + 音色) | |
| 170 | +- `drama_emotions` 表记录(默认表情映射) | |
| 171 | +- `drama_scripts` 表记录(台词序列) | |
| 172 | + | |
| 173 | +### 第 6 步:清理并输出结果 | |
| 174 | + | |
| 175 | +1. 删除临时 JSON 文件 | |
| 176 | +2. 告知用户: | |
| 177 | + - 剧本名称和 dramaKey | |
| 178 | + - 角色列表及音色 | |
| 179 | + - 台词数量 | |
| 180 | + - 图片生成结果(成功/失败) | |
| 181 | + - 简要剧情概述 | |
| 182 | + | |
| 183 | +## 配置说明 | |
| 184 | + | |
| 185 | +脚本会自动从以下位置加载配置(按优先级): | |
| 186 | +1. `--env <path>` 参数指定的 .env 文件 | |
| 187 | +2. 环境变量 `DRAMA_ENV_PATH` 指定的 .env 文件 | |
| 188 | +3. skill 目录下的 `.env` 文件 | |
| 189 | +4. 已有的环境变量 | |
| 190 | + | |
| 191 | +所需配置项: | |
| 192 | + | |
| 193 | +```env | |
| 194 | +# 数据库 | |
| 195 | +DB_HOST=127.0.0.1 | |
| 196 | +DB_PORT=3306 | |
| 197 | +DB_USER=root | |
| 198 | +DB_PASSWORD=xxx | |
| 199 | +DB_NAME=shadiao_drama | |
| 200 | + | |
| 201 | +# 阿里云 OSS(图片上传) | |
| 202 | +OSS_REGION=oss-cn-beijing | |
| 203 | +OSS_ACCESS_KEY_ID=xxx | |
| 204 | +OSS_ACCESS_KEY_SECRET=xxx | |
| 205 | +OSS_BUCKET=xxx | |
| 206 | +OSS_ENDPOINT=https://oss-cn-beijing.aliyuncs.com | |
| 207 | + | |
| 208 | +# 万界生图 API | |
| 209 | +WANJIE_API_KEY=xxx | |
| 210 | +``` | |
| 211 | + | |
| 212 | +## 注意事项 | |
| 213 | + | |
| 214 | +- **默认使用英文**:所有内容(name、title、subtitle、description、systemPrompt、台词、tags、imagePrompt、backgroundPrompt、coverPrompt)默认用英文生成,除非用户明确要求中文 | |
| 215 | +- 英文内容必须使用英文多情感音色(Candice, Serena, Glen, Sylus, Corey, Nadia) | |
| 216 | +- 风格偏沙雕搞笑,但要保留素材的核心信息 | |
| 217 | +- 如果素材是严肃新闻,可以用吐槽/解说风格改编 | |
| 218 | +- 角色名字要有趣,符合素材主题 | |
| 219 | +- 如果用户明确要求中文,则使用中文音色 | |
| 220 | + | |
| 221 | +## 复用到其他项目 | |
| 222 | + | |
| 223 | +整个 `create-drama/` 目录是自包含的,复制到其他项目的 `.claude/skills/` 即可使用。 | |
| 224 | + | |
| 225 | +**前置条件**: | |
| 226 | +1. Node.js 依赖:`npm i -g sequelize mysql2 ali-oss sharp` | |
| 227 | +2. Python 工具:`pip install rembg`(可选,用于角色图去背景) | |
| 228 | +3. 在 `create-drama/` 目录下修改 `.env` 配置 | |
| 229 | + | |
| 230 | +**目录结构**: | |
| 231 | +``` | |
| 232 | +.claude/skills/create-drama/ | |
| 233 | +├── SKILL.md # Skill 定义(本文件) | |
| 234 | +├── reference.md # 完整音色参考文档 | |
| 235 | +├── insert-drama.js # 数据库入库脚本 | |
| 236 | +├── generate-images.js # 图片生成 + 去背景 + OSS 上传脚本 | |
| 237 | +└── .env # 数据库 + OSS + 生图 API 配置 | |
| 238 | +``` | |
| 239 | + | |
| 240 | +## Additional Resources | |
| 241 | + | |
| 242 | +- 完整音色列表及情感参数:[reference.md](reference.md) | ... | ... |
docs/create-drama/generate-images.js
0 → 100644
| 1 | +#!/usr/bin/env node | |
| 2 | +/** | |
| 3 | + * generate-images.js — 为剧本生成角色图、背景图、封面图,并上传到 OSS | |
| 4 | + * | |
| 5 | + * 用法: | |
| 6 | + * node generate-images.js <drama-json文件> | |
| 7 | + * | |
| 8 | + * 输入 JSON 中需要包含: | |
| 9 | + * - characters[].imagePrompt (角色生图 prompt,由 SKILL.md 指导 Claude 生成) | |
| 10 | + * - backgroundPrompt (背景图 prompt) | |
| 11 | + * - coverPrompt (封面图 prompt) | |
| 12 | + * - dramaKey (用于 OSS 路径) | |
| 13 | + * | |
| 14 | + * 输出: 在原 JSON 基础上补充 URL 字段,写回文件 | |
| 15 | + * - characters[].bodyUrl | |
| 16 | + * - coverUrl | |
| 17 | + * - backgroundUrl | |
| 18 | + * | |
| 19 | + * 依赖: ali-oss, sharp (可选), rembg (Python, 可选) | |
| 20 | + */ | |
| 21 | + | |
| 22 | +const path = require('path'); | |
| 23 | +const fs = require('fs'); | |
| 24 | +const https = require('https'); | |
| 25 | +const http = require('http'); | |
| 26 | +const { execSync } = require('child_process'); | |
| 27 | +const os = require('os'); | |
| 28 | + | |
| 29 | +// ---------- 加载 .env ---------- | |
| 30 | +function loadEnv() { | |
| 31 | + const envArgIdx = process.argv.indexOf('--env'); | |
| 32 | + const candidates = [ | |
| 33 | + envArgIdx !== -1 ? path.resolve(process.argv[envArgIdx + 1]) : null, | |
| 34 | + process.env.DRAMA_ENV_PATH ? path.resolve(process.env.DRAMA_ENV_PATH) : null, | |
| 35 | + path.resolve(__dirname, '.env'), | |
| 36 | + ].filter(Boolean); | |
| 37 | + | |
| 38 | + for (const envPath of candidates) { | |
| 39 | + if (fs.existsSync(envPath)) { | |
| 40 | + const envContent = fs.readFileSync(envPath, 'utf-8'); | |
| 41 | + for (const line of envContent.split('\n')) { | |
| 42 | + const trimmed = line.trim(); | |
| 43 | + if (!trimmed || trimmed.startsWith('#')) continue; | |
| 44 | + const eqIdx = trimmed.indexOf('='); | |
| 45 | + if (eqIdx === -1) continue; | |
| 46 | + const key = trimmed.slice(0, eqIdx).trim(); | |
| 47 | + const val = trimmed.slice(eqIdx + 1).trim(); | |
| 48 | + if (!process.env[key]) process.env[key] = val; | |
| 49 | + } | |
| 50 | + console.log(`已加载配置: ${envPath}`); | |
| 51 | + return; | |
| 52 | + } | |
| 53 | + } | |
| 54 | + console.log('未找到 .env 文件,使用环境变量'); | |
| 55 | +} | |
| 56 | +loadEnv(); | |
| 57 | + | |
| 58 | +// ========== Wanjie 图片生成 API ========== | |
| 59 | + | |
| 60 | +const WANJIE_BASE = 'maas-openapi.wanjiedata.com'; | |
| 61 | + | |
| 62 | +function wanjieRequest(action, body) { | |
| 63 | + const apiKey = process.env.WANJIE_API_KEY; | |
| 64 | + if (!apiKey) throw new Error('缺少 WANJIE_API_KEY 环境变量'); | |
| 65 | + | |
| 66 | + const jsonBody = JSON.stringify(body); | |
| 67 | + return new Promise((resolve, reject) => { | |
| 68 | + const req = https.request({ | |
| 69 | + hostname: WANJIE_BASE, | |
| 70 | + path: `/api/jimeng/v1?Action=${action}&Version=2022-08-31`, | |
| 71 | + method: 'POST', | |
| 72 | + headers: { | |
| 73 | + 'Authorization': apiKey, | |
| 74 | + 'Content-Type': 'application/json', | |
| 75 | + 'Content-Length': Buffer.byteLength(jsonBody), | |
| 76 | + }, | |
| 77 | + }, (res) => { | |
| 78 | + let data = ''; | |
| 79 | + res.on('data', chunk => data += chunk); | |
| 80 | + res.on('end', () => { | |
| 81 | + try { resolve(JSON.parse(data)); } | |
| 82 | + catch (err) { reject(new Error(`解析响应失败: ${data.slice(0, 300)}`)); } | |
| 83 | + }); | |
| 84 | + }); | |
| 85 | + req.on('error', reject); | |
| 86 | + req.write(jsonBody); | |
| 87 | + req.end(); | |
| 88 | + }); | |
| 89 | +} | |
| 90 | + | |
| 91 | +/** Jimeng 文生图(异步模式) */ | |
| 92 | +async function generateImageJimeng(prompt, width = 1024, height = 1024) { | |
| 93 | + const res = await wanjieRequest('CVSync2AsyncSubmitTask', { | |
| 94 | + req_key: 'jimeng_t2i_v40', | |
| 95 | + prompt, width, height, | |
| 96 | + }); | |
| 97 | + | |
| 98 | + if (res.code !== 10000 || !res.data?.task_id) { | |
| 99 | + throw new Error(`Jimeng 提交失败: ${JSON.stringify(res).slice(0, 300)}`); | |
| 100 | + } | |
| 101 | + | |
| 102 | + const taskId = res.data.task_id; | |
| 103 | + console.log(` [Jimeng] 任务提交: task_id=${taskId}`); | |
| 104 | + | |
| 105 | + // 轮询等待 | |
| 106 | + const start = Date.now(); | |
| 107 | + while (Date.now() - start < 120000) { | |
| 108 | + const poll = await wanjieRequest('CVSync2AsyncGetResult', { | |
| 109 | + req_key: 'jimeng_t2i_v40', task_id: taskId, | |
| 110 | + }); | |
| 111 | + | |
| 112 | + if (poll.code === 10000 && poll.data?.image_urls?.length > 0) { | |
| 113 | + const buffer = await downloadUrl(poll.data.image_urls[0]); | |
| 114 | + return { buffer, mimeType: 'image/png' }; | |
| 115 | + } | |
| 116 | + if (poll.code === 10000 && poll.data?.binary_data_base64?.length > 0) { | |
| 117 | + return { buffer: Buffer.from(poll.data.binary_data_base64[0], 'base64'), mimeType: 'image/png' }; | |
| 118 | + } | |
| 119 | + if (poll.code && poll.code !== 10000 && !poll.message?.includes('processing') && !poll.message?.includes('pending')) { | |
| 120 | + throw new Error(`Jimeng 任务失败: ${JSON.stringify(poll).slice(0, 300)}`); | |
| 121 | + } | |
| 122 | + | |
| 123 | + await sleep(3000); | |
| 124 | + } | |
| 125 | + throw new Error(`Jimeng 超时: task_id=${taskId}`); | |
| 126 | +} | |
| 127 | + | |
| 128 | +/** Nano Banana Pro 文生图(同步模式,fallback) */ | |
| 129 | +async function generateImageNano(prompt) { | |
| 130 | + const apiKey = process.env.WANJIE_API_KEY; | |
| 131 | + if (!apiKey) throw new Error('缺少 WANJIE_API_KEY'); | |
| 132 | + | |
| 133 | + const url = `https://${WANJIE_BASE}/api/v1beta/models/gemini-3-pro-image-preview:generateContent`; | |
| 134 | + const body = JSON.stringify({ contents: [{ parts: [{ text: prompt }] }] }); | |
| 135 | + | |
| 136 | + for (let attempt = 1; attempt <= 3; attempt++) { | |
| 137 | + try { | |
| 138 | + const data = await httpsPost(url, body, { 'Authorization': `Bearer ${apiKey}` }); | |
| 139 | + const response = JSON.parse(data); | |
| 140 | + const img = extractImageFromResponse(response); | |
| 141 | + if (img) return img; | |
| 142 | + throw new Error('无图片数据'); | |
| 143 | + } catch (err) { | |
| 144 | + if (err.statusCode === 429 && attempt < 3) { | |
| 145 | + console.warn(` [Nano] 429 限流,${3 * attempt}s 后重试...`); | |
| 146 | + await sleep(3000 * attempt); | |
| 147 | + continue; | |
| 148 | + } | |
| 149 | + throw err; | |
| 150 | + } | |
| 151 | + } | |
| 152 | +} | |
| 153 | + | |
| 154 | +function extractImageFromResponse(response) { | |
| 155 | + const parts = response?.candidates?.[0]?.content?.parts; | |
| 156 | + if (!parts) return null; | |
| 157 | + for (const part of parts) { | |
| 158 | + if (part.inlineData) { | |
| 159 | + return { buffer: Buffer.from(part.inlineData.data, 'base64'), mimeType: part.inlineData.mimeType || 'image/png' }; | |
| 160 | + } | |
| 161 | + if (part.text && part.text.includes('data:image')) { | |
| 162 | + const match = part.text.match(/data:image\/(png|jpeg);base64,([A-Za-z0-9+/=]+)/); | |
| 163 | + if (match) return { buffer: Buffer.from(match[2], 'base64'), mimeType: `image/${match[1]}` }; | |
| 164 | + } | |
| 165 | + } | |
| 166 | + return null; | |
| 167 | +} | |
| 168 | + | |
| 169 | +/** 生图:优先 Jimeng,fallback Nano */ | |
| 170 | +async function generateImage(prompt) { | |
| 171 | + try { | |
| 172 | + return await generateImageJimeng(prompt); | |
| 173 | + } catch (jimengErr) { | |
| 174 | + console.warn(` [Jimeng] 失败: ${jimengErr.message.slice(0, 80)},fallback Nano...`); | |
| 175 | + return await generateImageNano(prompt); | |
| 176 | + } | |
| 177 | +} | |
| 178 | + | |
| 179 | +// ========== 图片处理 ========== | |
| 180 | + | |
| 181 | +/** 去背景(rembg) */ | |
| 182 | +function removeBg(inputBuffer) { | |
| 183 | + const tmpDir = os.tmpdir(); | |
| 184 | + const ts = Date.now(); | |
| 185 | + const inputPath = path.join(tmpDir, `rembg_in_${ts}.png`); | |
| 186 | + const outputPath = path.join(tmpDir, `rembg_out_${ts}.png`); | |
| 187 | + | |
| 188 | + try { | |
| 189 | + fs.writeFileSync(inputPath, inputBuffer); | |
| 190 | + execSync(`rembg i "${inputPath}" "${outputPath}"`, { timeout: 60000 }); | |
| 191 | + const result = fs.readFileSync(outputPath); | |
| 192 | + console.log(' [rembg] 去背景成功'); | |
| 193 | + | |
| 194 | + // 尝试 sharp 裁剪 | |
| 195 | + try { | |
| 196 | + const sharp = require('sharp'); | |
| 197 | + // sharp 操作是 async,但我们在 sync context 用 execSync 调 sharp | |
| 198 | + // 改用 async | |
| 199 | + return trimImage(result); | |
| 200 | + } catch { | |
| 201 | + return result; | |
| 202 | + } | |
| 203 | + } catch (err) { | |
| 204 | + console.warn(` [rembg] 失败(${err.message.slice(0, 60)}),返回原图`); | |
| 205 | + return inputBuffer; | |
| 206 | + } finally { | |
| 207 | + try { fs.unlinkSync(inputPath); } catch { } | |
| 208 | + try { fs.unlinkSync(outputPath); } catch { } | |
| 209 | + } | |
| 210 | +} | |
| 211 | + | |
| 212 | +async function trimImage(buffer) { | |
| 213 | + try { | |
| 214 | + const sharp = require('sharp'); | |
| 215 | + const trimmed = await sharp(buffer).trim().png().toBuffer(); | |
| 216 | + const meta = await sharp(trimmed).metadata(); | |
| 217 | + const padX = Math.round(meta.width * 0.05); | |
| 218 | + const padY = Math.round(meta.height * 0.05); | |
| 219 | + return await sharp(trimmed) | |
| 220 | + .extend({ top: padY, bottom: padY, left: padX, right: padX, background: { r: 0, g: 0, b: 0, alpha: 0 } }) | |
| 221 | + .png().toBuffer(); | |
| 222 | + } catch { | |
| 223 | + return buffer; | |
| 224 | + } | |
| 225 | +} | |
| 226 | + | |
| 227 | +// ========== OSS 上传 ========== | |
| 228 | + | |
| 229 | +function getOSSClient() { | |
| 230 | + const OSS = require('ali-oss'); | |
| 231 | + return new OSS({ | |
| 232 | + region: process.env.OSS_REGION, | |
| 233 | + accessKeyId: process.env.OSS_ACCESS_KEY_ID, | |
| 234 | + accessKeySecret: process.env.OSS_ACCESS_KEY_SECRET, | |
| 235 | + bucket: process.env.OSS_BUCKET, | |
| 236 | + }); | |
| 237 | +} | |
| 238 | + | |
| 239 | +function getPublicUrl(key) { | |
| 240 | + const endpoint = process.env.OSS_ENDPOINT || `https://${process.env.OSS_REGION}.aliyuncs.com`; | |
| 241 | + const bucket = process.env.OSS_BUCKET; | |
| 242 | + return `https://${bucket}.${endpoint.replace('https://', '')}/${key}`; | |
| 243 | +} | |
| 244 | + | |
| 245 | +async function uploadToOSS(ossKey, buffer, contentType = 'image/png') { | |
| 246 | + const client = getOSSClient(); | |
| 247 | + const result = await client.put(ossKey, buffer, { headers: { 'Content-Type': contentType } }); | |
| 248 | + return getPublicUrl(result.name); | |
| 249 | +} | |
| 250 | + | |
| 251 | +// ========== 工具函数 ========== | |
| 252 | + | |
| 253 | +function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } | |
| 254 | + | |
| 255 | +function httpsPost(url, body, extraHeaders = {}) { | |
| 256 | + return new Promise((resolve, reject) => { | |
| 257 | + const urlObj = new URL(url); | |
| 258 | + const req = https.request({ | |
| 259 | + hostname: urlObj.hostname, | |
| 260 | + path: urlObj.pathname + urlObj.search, | |
| 261 | + method: 'POST', | |
| 262 | + headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body), ...extraHeaders }, | |
| 263 | + }, (res) => { | |
| 264 | + let data = ''; | |
| 265 | + res.on('data', chunk => data += chunk); | |
| 266 | + res.on('end', () => { | |
| 267 | + if (res.statusCode === 429) { const e = new Error('429'); e.statusCode = 429; reject(e); return; } | |
| 268 | + resolve(data); | |
| 269 | + }); | |
| 270 | + }); | |
| 271 | + req.on('error', reject); | |
| 272 | + req.write(body); | |
| 273 | + req.end(); | |
| 274 | + }); | |
| 275 | +} | |
| 276 | + | |
| 277 | +function downloadUrl(url) { | |
| 278 | + return new Promise((resolve, reject) => { | |
| 279 | + const protocol = url.startsWith('https') ? https : http; | |
| 280 | + protocol.get(url, (res) => { | |
| 281 | + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { | |
| 282 | + return downloadUrl(res.headers.location).then(resolve).catch(reject); | |
| 283 | + } | |
| 284 | + const chunks = []; | |
| 285 | + res.on('data', chunk => chunks.push(chunk)); | |
| 286 | + res.on('end', () => resolve(Buffer.concat(chunks))); | |
| 287 | + res.on('error', reject); | |
| 288 | + }).on('error', reject); | |
| 289 | + }); | |
| 290 | +} | |
| 291 | + | |
| 292 | +// ========== 主逻辑 ========== | |
| 293 | + | |
| 294 | +async function main() { | |
| 295 | + const filePath = process.argv.find(a => a.endsWith('.json')); | |
| 296 | + if (!filePath) { | |
| 297 | + console.error('用法: node generate-images.js <drama-json文件>'); | |
| 298 | + process.exit(1); | |
| 299 | + } | |
| 300 | + | |
| 301 | + const absPath = path.resolve(filePath); | |
| 302 | + const data = JSON.parse(fs.readFileSync(absPath, 'utf-8')); | |
| 303 | + const dramaKey = data.dramaKey || `drama_${Date.now().toString(36)}`; | |
| 304 | + data.dramaKey = dramaKey; | |
| 305 | + | |
| 306 | + // 检查 OSS 配置 | |
| 307 | + const ossOk = process.env.OSS_REGION && process.env.OSS_ACCESS_KEY_ID && process.env.OSS_ACCESS_KEY_SECRET && process.env.OSS_BUCKET; | |
| 308 | + if (!ossOk) { | |
| 309 | + console.error('OSS 配置不完整,请检查 .env 中的 OSS_REGION, OSS_ACCESS_KEY_ID, OSS_ACCESS_KEY_SECRET, OSS_BUCKET'); | |
| 310 | + process.exit(1); | |
| 311 | + } | |
| 312 | + | |
| 313 | + // 检查生图 API 配置 | |
| 314 | + if (!process.env.WANJIE_API_KEY) { | |
| 315 | + console.error('缺少 WANJIE_API_KEY'); | |
| 316 | + process.exit(1); | |
| 317 | + } | |
| 318 | + | |
| 319 | + const characters = data.characters || []; | |
| 320 | + const total = characters.length + 2; // chars + background + cover | |
| 321 | + let completed = 0; | |
| 322 | + const tick = (label) => { completed++; console.log(`[${completed}/${total}] ${label} 完成`); }; | |
| 323 | + | |
| 324 | + // 并行处理所有图片 | |
| 325 | + const tasks = []; | |
| 326 | + | |
| 327 | + // 角色图 | |
| 328 | + for (const char of characters) { | |
| 329 | + tasks.push((async () => { | |
| 330 | + const prompt = char.imagePrompt || `全身立绘,纯色背景,沙雕动漫风格。角色:${char.name}。脸部空白无五官。`; | |
| 331 | + console.log(`\n生成角色图: ${char.name}`); | |
| 332 | + console.log(` prompt: ${prompt.slice(0, 80)}...`); | |
| 333 | + | |
| 334 | + const { buffer } = await generateImage(prompt); | |
| 335 | + | |
| 336 | + // 去背景 | |
| 337 | + console.log(` 正在去背景...`); | |
| 338 | + const processed = await removeBg(buffer); | |
| 339 | + | |
| 340 | + // 上传 OSS | |
| 341 | + const safeName = Buffer.from(char.name).toString('hex').slice(0, 16); | |
| 342 | + const ossKey = `dramas/${dramaKey}/characters/${safeName}.png`; | |
| 343 | + const url = await uploadToOSS(ossKey, processed); | |
| 344 | + char.bodyUrl = url; | |
| 345 | + console.log(` 上传完成: ${url}`); | |
| 346 | + tick(char.name); | |
| 347 | + })()); | |
| 348 | + } | |
| 349 | + | |
| 350 | + // 背景图 | |
| 351 | + tasks.push((async () => { | |
| 352 | + const prompt = data.backgroundPrompt || `横版场景背景图,宽屏比例,场景:${data.description || data.name},卡通风格,无人物。`; | |
| 353 | + console.log(`\n生成背景图`); | |
| 354 | + console.log(` prompt: ${prompt.slice(0, 80)}...`); | |
| 355 | + | |
| 356 | + const { buffer } = await generateImage(prompt); | |
| 357 | + const ossKey = `dramas/${dramaKey}/background.png`; | |
| 358 | + const url = await uploadToOSS(ossKey, buffer); | |
| 359 | + data.backgroundUrl = url; | |
| 360 | + console.log(` 上传完成: ${url}`); | |
| 361 | + tick('背景图'); | |
| 362 | + })()); | |
| 363 | + | |
| 364 | + // 封面图 | |
| 365 | + tasks.push((async () => { | |
| 366 | + const prompt = data.coverPrompt || `剧目封面海报,标题:${data.title || data.name},色彩鲜明。`; | |
| 367 | + console.log(`\n生成封面图`); | |
| 368 | + console.log(` prompt: ${prompt.slice(0, 80)}...`); | |
| 369 | + | |
| 370 | + const { buffer } = await generateImage(prompt); | |
| 371 | + const ossKey = `dramas/${dramaKey}/cover.png`; | |
| 372 | + const url = await uploadToOSS(ossKey, buffer); | |
| 373 | + data.coverUrl = url; | |
| 374 | + console.log(` 上传完成: ${url}`); | |
| 375 | + tick('封面图'); | |
| 376 | + })()); | |
| 377 | + | |
| 378 | + await Promise.allSettled(tasks); | |
| 379 | + | |
| 380 | + // 写回文件 | |
| 381 | + fs.writeFileSync(absPath, JSON.stringify(data, null, 2), 'utf-8'); | |
| 382 | + console.log(`\n图片生成完成,已更新 JSON 文件: ${absPath}`); | |
| 383 | + console.log(` 角色图: ${characters.filter(c => c.bodyUrl).length}/${characters.length}`); | |
| 384 | + console.log(` 背景图: ${data.backgroundUrl ? '✓' : '✗'}`); | |
| 385 | + console.log(` 封面图: ${data.coverUrl ? '✓' : '✗'}`); | |
| 386 | +} | |
| 387 | + | |
| 388 | +main().catch(err => { | |
| 389 | + console.error('执行失败:', err); | |
| 390 | + process.exit(1); | |
| 391 | +}); | ... | ... |
docs/create-drama/insert-drama.js
0 → 100644
| 1 | +#!/usr/bin/env node | |
| 2 | +/** | |
| 3 | + * insert-drama.js — 独立脚本:将 classic 类型剧本数据插入数据库 | |
| 4 | + * | |
| 5 | + * 用法: | |
| 6 | + * node insert-drama.js <json文件路径> | |
| 7 | + * echo '{"name":...}' | node insert-drama.js --stdin | |
| 8 | + * | |
| 9 | + * JSON 格式: | |
| 10 | + * { | |
| 11 | + * "name": "剧本名称", | |
| 12 | + * "title": "显示标题(可带 emoji)", | |
| 13 | + * "subtitle": "副标题", | |
| 14 | + * "description": "剧本简介", | |
| 15 | + * "systemPrompt": "用于 AI 续写的系统提示词", | |
| 16 | + * "isPublic": true, | |
| 17 | + * "userId": 1, | |
| 18 | + * "characters": [ | |
| 19 | + * { "name": "角色名", "gender": "male", "voiceId": "zh_male_lengkugege_emo_v2_mars_bigtts" } | |
| 20 | + * ], | |
| 21 | + * "script": [ | |
| 22 | + * { "character": "角色名", "dialogue": "台词", "avatar": "happy.png", "action": "none", "position": "left" } | |
| 23 | + * ] | |
| 24 | + * } | |
| 25 | + */ | |
| 26 | + | |
| 27 | +const path = require('path'); | |
| 28 | +const fs = require('fs'); | |
| 29 | + | |
| 30 | +// 加载 .env 文件(优先级:--env 参数 > 环境变量 DRAMA_ENV_PATH > 脚本同目录 .env) | |
| 31 | +function loadEnv() { | |
| 32 | + const envArgIdx = process.argv.indexOf('--env'); | |
| 33 | + const candidates = [ | |
| 34 | + envArgIdx !== -1 ? path.resolve(process.argv[envArgIdx + 1]) : null, | |
| 35 | + process.env.DRAMA_ENV_PATH ? path.resolve(process.env.DRAMA_ENV_PATH) : null, | |
| 36 | + path.resolve(__dirname, '.env'), | |
| 37 | + ].filter(Boolean); | |
| 38 | + | |
| 39 | + for (const envPath of candidates) { | |
| 40 | + if (fs.existsSync(envPath)) { | |
| 41 | + const envContent = fs.readFileSync(envPath, 'utf-8'); | |
| 42 | + for (const line of envContent.split('\n')) { | |
| 43 | + const trimmed = line.trim(); | |
| 44 | + if (!trimmed || trimmed.startsWith('#')) continue; | |
| 45 | + const eqIdx = trimmed.indexOf('='); | |
| 46 | + if (eqIdx === -1) continue; | |
| 47 | + const key = trimmed.slice(0, eqIdx).trim(); | |
| 48 | + const val = trimmed.slice(eqIdx + 1).trim(); | |
| 49 | + if (!process.env[key]) process.env[key] = val; | |
| 50 | + } | |
| 51 | + console.log(`已加载配置: ${envPath}`); | |
| 52 | + return; | |
| 53 | + } | |
| 54 | + } | |
| 55 | + console.log('未找到 .env 文件,使用环境变量或默认值'); | |
| 56 | +} | |
| 57 | +loadEnv(); | |
| 58 | + | |
| 59 | +const { Sequelize, DataTypes } = require('sequelize'); | |
| 60 | + | |
| 61 | +// ---------- DB 连接 ---------- | |
| 62 | +const sequelize = new Sequelize( | |
| 63 | + process.env.DB_NAME || 'shadiao_drama', | |
| 64 | + process.env.DB_USER || 'root', | |
| 65 | + process.env.DB_PASSWORD || '', | |
| 66 | + { | |
| 67 | + host: process.env.DB_HOST || '127.0.0.1', | |
| 68 | + port: parseInt(process.env.DB_PORT || '3306', 10), | |
| 69 | + dialect: 'mysql', | |
| 70 | + logging: false, | |
| 71 | + timezone: '+08:00', | |
| 72 | + define: { charset: 'utf8mb4', collate: 'utf8mb4_unicode_ci' }, | |
| 73 | + } | |
| 74 | +); | |
| 75 | + | |
| 76 | +// ---------- Model 定义(内联,避免依赖项目代码) ---------- | |
| 77 | +const Drama = sequelize.define('Drama', { | |
| 78 | + id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true }, | |
| 79 | + dramaKey: { type: DataTypes.STRING(50), allowNull: false, unique: true, field: 'drama_key' }, | |
| 80 | + name: { type: DataTypes.STRING(100), allowNull: false }, | |
| 81 | + title: { type: DataTypes.STRING(100), allowNull: true }, | |
| 82 | + subtitle: { type: DataTypes.STRING(200), allowNull: true }, | |
| 83 | + description: { type: DataTypes.TEXT, allowNull: true }, | |
| 84 | + coverUrl: { type: DataTypes.STRING(500), allowNull: true, field: 'cover_url' }, | |
| 85 | + backgroundUrl: { type: DataTypes.STRING(500), allowNull: true, field: 'background_url' }, | |
| 86 | + systemPrompt: { type: DataTypes.TEXT, allowNull: true, field: 'system_prompt' }, | |
| 87 | + status: { type: DataTypes.TINYINT, defaultValue: 1 }, | |
| 88 | + userId: { type: DataTypes.INTEGER, allowNull: true, field: 'user_id' }, | |
| 89 | + isPublic: { type: DataTypes.BOOLEAN, defaultValue: true, field: 'is_public' }, | |
| 90 | + type: { type: DataTypes.STRING(30), defaultValue: 'classic' }, | |
| 91 | + emojiPack: { type: DataTypes.STRING(50), defaultValue: 'default', field: 'emoji_pack' }, | |
| 92 | + vrmConfig: { type: DataTypes.TEXT, allowNull: true, field: 'vrm_config' }, | |
| 93 | + tags: { type: DataTypes.STRING(255), allowNull: true }, | |
| 94 | + score: { type: DataTypes.DECIMAL(10, 2), defaultValue: 0 }, | |
| 95 | +}, { tableName: 'dramas', timestamps: true, underscored: true }); | |
| 96 | + | |
| 97 | +const DramaCharacter = sequelize.define('DramaCharacter', { | |
| 98 | + id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true }, | |
| 99 | + dramaId: { type: DataTypes.INTEGER, allowNull: false, field: 'drama_id' }, | |
| 100 | + characterName: { type: DataTypes.STRING(50), allowNull: false, field: 'character_name' }, | |
| 101 | + bodyUrl: { type: DataTypes.STRING(500), allowNull: true, field: 'body_url' }, | |
| 102 | + faceOffsetTop: { type: DataTypes.STRING(20), allowNull: true, field: 'face_offset_top' }, | |
| 103 | + faceOffsetLeft: { type: DataTypes.STRING(20), allowNull: true, field: 'face_offset_left' }, | |
| 104 | + faceOffsetWidth: { type: DataTypes.STRING(20), allowNull: true, field: 'face_offset_width' }, | |
| 105 | + voiceId: { type: DataTypes.STRING(100), allowNull: true, field: 'voice_id' }, | |
| 106 | + isDefault: { type: DataTypes.TINYINT, defaultValue: 0, field: 'is_default' }, | |
| 107 | +}, { tableName: 'drama_characters', timestamps: false, underscored: true }); | |
| 108 | + | |
| 109 | +const DramaEmotion = sequelize.define('DramaEmotion', { | |
| 110 | + id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true }, | |
| 111 | + dramaId: { type: DataTypes.INTEGER, allowNull: false, field: 'drama_id' }, | |
| 112 | + avatarKey: { type: DataTypes.STRING(50), allowNull: false, field: 'avatar_key' }, | |
| 113 | + faceUrl: { type: DataTypes.STRING(500), allowNull: true, field: 'face_url' }, | |
| 114 | + emotionName: { type: DataTypes.STRING(30), allowNull: true, field: 'emotion_name' }, | |
| 115 | + emotionScale: { type: DataTypes.INTEGER, defaultValue: 3, field: 'emotion_scale' }, | |
| 116 | + isDefault: { type: DataTypes.TINYINT, defaultValue: 0, field: 'is_default' }, | |
| 117 | + characterName: { type: DataTypes.STRING(50), allowNull: true, field: 'character_name' }, | |
| 118 | +}, { tableName: 'drama_emotions', timestamps: false, underscored: true }); | |
| 119 | + | |
| 120 | +const DramaScript = sequelize.define('DramaScript', { | |
| 121 | + id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true }, | |
| 122 | + dramaId: { type: DataTypes.INTEGER, allowNull: false, field: 'drama_id' }, | |
| 123 | + seq: { type: DataTypes.INTEGER, allowNull: false }, | |
| 124 | + characterName: { type: DataTypes.STRING(50), allowNull: false, field: 'character_name' }, | |
| 125 | + avatar: { type: DataTypes.STRING(50), allowNull: true }, | |
| 126 | + dialogue: { type: DataTypes.TEXT, allowNull: false }, | |
| 127 | + action: { type: DataTypes.STRING(30), allowNull: true }, | |
| 128 | + position: { type: DataTypes.STRING(10), allowNull: true }, | |
| 129 | + sfx: { type: DataTypes.STRING(100), allowNull: true }, | |
| 130 | + audio: { type: DataTypes.STRING(500), allowNull: true }, | |
| 131 | +}, { tableName: 'drama_scripts', timestamps: false, underscored: true }); | |
| 132 | + | |
| 133 | +// ---------- 默认表情配置 ---------- | |
| 134 | +const DEFAULT_EMOJI_BASE = 'https://shadiao-drama.oss-cn-beijing.aliyuncs.com/dramas/zhenhuan_chuan/emojis'; | |
| 135 | +const DEFAULT_EMOJIS = [ | |
| 136 | + { key: 'normal.png', name: '正常', emotion: 'neutral', scale: 3 }, | |
| 137 | + { key: 'happy.png', name: '开心', emotion: 'happy', scale: 4 }, | |
| 138 | + { key: 'very_happy.png', name: '非常开心', emotion: 'happy', scale: 5 }, | |
| 139 | + { key: 'angry.png', name: '生气', emotion: 'angry', scale: 4 }, | |
| 140 | + { key: 'sad.png', name: '伤心', emotion: 'sad', scale: 4 }, | |
| 141 | + { key: 'cry.png', name: '哭', emotion: 'sad', scale: 5 }, | |
| 142 | + { key: 'shock.png', name: '惊讶', emotion: 'surprised', scale: 5 }, | |
| 143 | + { key: 'smirk.png', name: '撇嘴', emotion: 'happy', scale: 3 }, | |
| 144 | + { key: 'speechless.png', name: '无语', emotion: 'neutral', scale: 2 }, | |
| 145 | +]; | |
| 146 | + | |
| 147 | +// ---------- 音色情绪兼容映射 ---------- | |
| 148 | +const VOICE_EMOTIONS = { | |
| 149 | + 'zh_male_lengkugege_emo_v2_mars_bigtts': ['angry','coldness','fear','happy','hate','neutral','sad','depressed'], | |
| 150 | + 'zh_female_tianxinxiaomei_emo_v2_mars_bigtts': ['sad','fear','hate','neutral'], | |
| 151 | + 'zh_female_gaolengyujie_emo_v2_mars_bigtts': ['happy','sad','angry','surprised','fear','hate','excited','coldness','neutral'], | |
| 152 | + 'zh_male_aojiaobazong_emo_v2_mars_bigtts': ['neutral','happy','angry','hate'], | |
| 153 | + 'zh_male_guangzhoudege_emo_mars_bigtts': ['angry','fear','neutral'], | |
| 154 | + 'zh_male_jingqiangkanye_emo_mars_bigtts': ['happy','angry','surprised','hate','neutral'], | |
| 155 | + 'zh_female_linjuayi_emo_v2_mars_bigtts': ['neutral','angry','coldness','depressed','surprised'], | |
| 156 | + 'zh_male_yourougongzi_emo_v2_mars_bigtts': ['happy','angry','fear','hate','excited','neutral','depressed'], | |
| 157 | + 'zh_male_ruyayichen_emo_v2_mars_bigtts': ['happy','sad','angry','fear','excited','coldness','neutral'], | |
| 158 | + 'zh_male_junlangnanyou_emo_v2_mars_bigtts': ['happy','sad','angry','surprised','fear','neutral'], | |
| 159 | + 'zh_male_beijingxiaoye_emo_v2_mars_bigtts': ['angry','surprised','fear','excited','coldness','neutral'], | |
| 160 | + 'zh_female_roumeinvyou_emo_v2_mars_bigtts': ['happy','sad','angry','surprised','fear','hate','excited','coldness','neutral'], | |
| 161 | + 'zh_male_yangguangqingnian_emo_v2_mars_bigtts': ['happy','sad','angry','fear','excited','coldness','neutral'], | |
| 162 | + 'zh_female_meilinvyou_emo_v2_mars_bigtts': ['sad','fear','neutral'], | |
| 163 | + 'zh_female_shuangkuaisisi_emo_v2_mars_bigtts': ['happy','sad','angry','surprised','excited','coldness','neutral'], | |
| 164 | + 'zh_male_shenyeboke_emo_v2_mars_bigtts': ['surprised','sad','neutral','hate','happy','fear','excited','depressed','coldness','angry'], | |
| 165 | + 'zh_male_zhoujielun_emo_v2_mars_bigtts': ['neutral'], | |
| 166 | +}; | |
| 167 | + | |
| 168 | +function validateEmotion(speaker, emotion) { | |
| 169 | + if (!emotion || emotion === 'neutral') return emotion; | |
| 170 | + const supported = VOICE_EMOTIONS[speaker]; | |
| 171 | + if (!supported) return null; | |
| 172 | + return supported.includes(emotion) ? emotion : 'neutral'; | |
| 173 | +} | |
| 174 | + | |
| 175 | +// ---------- 构建默认表情记录 ---------- | |
| 176 | +function buildEmotionRecords(dramaId, characters, defaultVoice) { | |
| 177 | + const records = []; | |
| 178 | + const charVoiceMap = {}; | |
| 179 | + if (characters) { | |
| 180 | + for (const char of characters) { | |
| 181 | + if (char.voiceId) charVoiceMap[char.name] = char.voiceId; | |
| 182 | + } | |
| 183 | + } | |
| 184 | + | |
| 185 | + // 共享默认表情 | |
| 186 | + for (const emo of DEFAULT_EMOJIS) { | |
| 187 | + const validEmotion = validateEmotion(defaultVoice, emo.emotion) || 'neutral'; | |
| 188 | + records.push({ | |
| 189 | + dramaId, | |
| 190 | + avatarKey: emo.key, | |
| 191 | + faceUrl: `${DEFAULT_EMOJI_BASE}/${encodeURIComponent(emo.name)}.png`, | |
| 192 | + emotionName: validEmotion, | |
| 193 | + emotionScale: emo.scale, | |
| 194 | + isDefault: 0, | |
| 195 | + characterName: null, | |
| 196 | + }); | |
| 197 | + } | |
| 198 | + records.push({ | |
| 199 | + dramaId, | |
| 200 | + avatarKey: '_default', | |
| 201 | + faceUrl: `${DEFAULT_EMOJI_BASE}/${encodeURIComponent('正常')}.png`, | |
| 202 | + emotionName: 'neutral', | |
| 203 | + emotionScale: 3, | |
| 204 | + isDefault: 1, | |
| 205 | + characterName: null, | |
| 206 | + }); | |
| 207 | + | |
| 208 | + return records; | |
| 209 | +} | |
| 210 | + | |
| 211 | +// ---------- 主逻辑 ---------- | |
| 212 | +async function main() { | |
| 213 | + let jsonStr; | |
| 214 | + | |
| 215 | + if (process.argv.includes('--stdin')) { | |
| 216 | + // 从 stdin 读取 | |
| 217 | + jsonStr = ''; | |
| 218 | + for await (const chunk of process.stdin) { | |
| 219 | + jsonStr += chunk; | |
| 220 | + } | |
| 221 | + } else if (process.argv[2]) { | |
| 222 | + // 从文件读取 | |
| 223 | + const filePath = path.resolve(process.argv[2]); | |
| 224 | + if (!fs.existsSync(filePath)) { | |
| 225 | + console.error(`文件不存在: ${filePath}`); | |
| 226 | + process.exit(1); | |
| 227 | + } | |
| 228 | + jsonStr = fs.readFileSync(filePath, 'utf-8'); | |
| 229 | + } else { | |
| 230 | + console.error('用法: node insert-drama.js <json文件> 或 node insert-drama.js --stdin'); | |
| 231 | + process.exit(1); | |
| 232 | + } | |
| 233 | + | |
| 234 | + let data; | |
| 235 | + try { | |
| 236 | + data = JSON.parse(jsonStr); | |
| 237 | + } catch (e) { | |
| 238 | + console.error('JSON 解析失败:', e.message); | |
| 239 | + process.exit(1); | |
| 240 | + } | |
| 241 | + | |
| 242 | + const { name, title, subtitle, description, systemPrompt, isPublic, userId, characters, script, tags } = data; | |
| 243 | + | |
| 244 | + if (!name || !script || !Array.isArray(script) || script.length === 0) { | |
| 245 | + console.error('缺少必要字段: name, script'); | |
| 246 | + process.exit(1); | |
| 247 | + } | |
| 248 | + if (!characters || !Array.isArray(characters) || characters.length === 0) { | |
| 249 | + console.error('缺少必要字段: characters'); | |
| 250 | + process.exit(1); | |
| 251 | + } | |
| 252 | + | |
| 253 | + // 连接数据库 | |
| 254 | + await sequelize.authenticate(); | |
| 255 | + console.log('数据库连接成功'); | |
| 256 | + | |
| 257 | + const t = await sequelize.transaction(); | |
| 258 | + | |
| 259 | + try { | |
| 260 | + const dramaKey = `user_${userId || 1}_${Date.now().toString(36)}`; | |
| 261 | + | |
| 262 | + // 1. 创建 Drama 记录 | |
| 263 | + const drama = await Drama.create({ | |
| 264 | + dramaKey, | |
| 265 | + name, | |
| 266 | + title: title || name, | |
| 267 | + subtitle: subtitle || '', | |
| 268 | + description: description || '', | |
| 269 | + coverUrl: data.coverUrl || '', | |
| 270 | + backgroundUrl: data.backgroundUrl || '', | |
| 271 | + systemPrompt: systemPrompt || '', | |
| 272 | + status: 1, | |
| 273 | + userId: userId || null, | |
| 274 | + isPublic: isPublic !== false, | |
| 275 | + type: 'classic', | |
| 276 | + emojiPack: 'default', | |
| 277 | + tags: tags || null, | |
| 278 | + }, { transaction: t }); | |
| 279 | + | |
| 280 | + // 2. 创建角色记录 | |
| 281 | + const charRecords = characters.map(char => ({ | |
| 282 | + dramaId: drama.id, | |
| 283 | + characterName: char.name, | |
| 284 | + bodyUrl: char.bodyUrl || '', | |
| 285 | + faceOffsetTop: char.faceOffset?.top || '8%', | |
| 286 | + faceOffsetLeft: char.faceOffset?.left || '35%', | |
| 287 | + faceOffsetWidth: char.faceOffset?.width || '30%', | |
| 288 | + voiceId: char.voiceId || '', | |
| 289 | + isDefault: char.isDefault ? 1 : 0, | |
| 290 | + })); | |
| 291 | + // 添加 _default 角色 | |
| 292 | + const firstChar = characters[0]; | |
| 293 | + charRecords.push({ | |
| 294 | + dramaId: drama.id, | |
| 295 | + characterName: '_default', | |
| 296 | + bodyUrl: firstChar.bodyUrl || '', | |
| 297 | + faceOffsetTop: firstChar.faceOffset?.top || '8%', | |
| 298 | + faceOffsetLeft: firstChar.faceOffset?.left || '35%', | |
| 299 | + faceOffsetWidth: firstChar.faceOffset?.width || '30%', | |
| 300 | + voiceId: firstChar.voiceId || '', | |
| 301 | + isDefault: 1, | |
| 302 | + }); | |
| 303 | + await DramaCharacter.bulkCreate(charRecords, { transaction: t }); | |
| 304 | + | |
| 305 | + // 3. 创建表情记录 | |
| 306 | + const defaultVoice = characters[0]?.voiceId || ''; | |
| 307 | + const emotionRecords = buildEmotionRecords(drama.id, characters, defaultVoice); | |
| 308 | + await DramaEmotion.bulkCreate(emotionRecords, { transaction: t }); | |
| 309 | + | |
| 310 | + // 4. 创建台词记录 | |
| 311 | + const scriptRecords = script.map((line, i) => ({ | |
| 312 | + dramaId: drama.id, | |
| 313 | + seq: i, | |
| 314 | + characterName: line.character, | |
| 315 | + avatar: line.avatar || 'normal.png', | |
| 316 | + dialogue: line.dialogue, | |
| 317 | + action: line.action || 'none', | |
| 318 | + position: line.position || 'left', | |
| 319 | + sfx: line.sfx || null, | |
| 320 | + audio: null, | |
| 321 | + })); | |
| 322 | + await DramaScript.bulkCreate(scriptRecords, { transaction: t }); | |
| 323 | + | |
| 324 | + await t.commit(); | |
| 325 | + | |
| 326 | + console.log(`\n剧本创建成功!`); | |
| 327 | + console.log(` ID: ${drama.id}`); | |
| 328 | + console.log(` Key: ${dramaKey}`); | |
| 329 | + console.log(` 名称: ${name}`); | |
| 330 | + console.log(` 角色数: ${characters.length}`); | |
| 331 | + console.log(` 台词数: ${script.length}`); | |
| 332 | + | |
| 333 | + // 输出 JSON 结果供调用方解析 | |
| 334 | + console.log(`\n__RESULT__${JSON.stringify({ id: drama.id, dramaKey, name })}__END__`); | |
| 335 | + } catch (err) { | |
| 336 | + await t.rollback(); | |
| 337 | + console.error('插入失败:', err.message); | |
| 338 | + process.exit(1); | |
| 339 | + } finally { | |
| 340 | + await sequelize.close(); | |
| 341 | + } | |
| 342 | +} | |
| 343 | + | |
| 344 | +main().catch(err => { | |
| 345 | + console.error('执行失败:', err); | |
| 346 | + process.exit(1); | |
| 347 | +}); | ... | ... |
docs/create-drama/reference.md
0 → 100644
| 1 | +# 音色参考文档 | |
| 2 | + | |
| 3 | +## 多情感音色(推荐优先使用) | |
| 4 | + | |
| 5 | +### 中文多情感 | |
| 6 | + | |
| 7 | +| 音色名称 | voice_type | 支持的情感 | | |
| 8 | +|---------|-----------|-----------| | |
| 9 | +| 冷酷哥哥 | `zh_male_lengkugege_emo_v2_mars_bigtts` | angry, coldness, fear, happy, hate, neutral, sad, depressed | | |
| 10 | +| 甜心小美 | `zh_female_tianxinxiaomei_emo_v2_mars_bigtts` | sad, fear, hate, neutral | | |
| 11 | +| 高冷御姐 | `zh_female_gaolengyujie_emo_v2_mars_bigtts` | happy, sad, angry, surprised, fear, hate, excited, coldness, neutral | | |
| 12 | +| 傲娇霸总 | `zh_male_aojiaobazong_emo_v2_mars_bigtts` | neutral, happy, angry, hate | | |
| 13 | +| 广州德哥 | `zh_male_guangzhoudege_emo_mars_bigtts` | angry, fear, neutral | | |
| 14 | +| 京腔侃爷 | `zh_male_jingqiangkanye_emo_mars_bigtts` | happy, angry, surprised, hate, neutral | | |
| 15 | +| 邻居阿姨 | `zh_female_linjuayi_emo_v2_mars_bigtts` | neutral, angry, coldness, depressed, surprised | | |
| 16 | +| 优柔公子 | `zh_male_yourougongzi_emo_v2_mars_bigtts` | happy, angry, fear, hate, excited, neutral, depressed | | |
| 17 | +| 儒雅男友 | `zh_male_ruyayichen_emo_v2_mars_bigtts` | happy, sad, angry, fear, excited, coldness, neutral | | |
| 18 | +| 俊朗男友 | `zh_male_junlangnanyou_emo_v2_mars_bigtts` | happy, sad, angry, surprised, fear, neutral | | |
| 19 | +| 北京小爷 | `zh_male_beijingxiaoye_emo_v2_mars_bigtts` | angry, surprised, fear, excited, coldness, neutral | | |
| 20 | +| 柔美女友 | `zh_female_roumeinvyou_emo_v2_mars_bigtts` | happy, sad, angry, surprised, fear, hate, excited, coldness, neutral | | |
| 21 | +| 阳光青年 | `zh_male_yangguangqingnian_emo_v2_mars_bigtts` | happy, sad, angry, fear, excited, coldness, neutral | | |
| 22 | +| 魅力女友 | `zh_female_meilinvyou_emo_v2_mars_bigtts` | sad, fear, neutral | | |
| 23 | +| 爽快思思 | `zh_female_shuangkuaisisi_emo_v2_mars_bigtts` | happy, sad, angry, surprised, excited, coldness, neutral | | |
| 24 | +| 深夜播客 | `zh_male_shenyeboke_emo_v2_mars_bigtts` | surprised, sad, neutral, hate, happy, fear, excited, depressed, coldness, angry | | |
| 25 | + | |
| 26 | +### 英文多情感 | |
| 27 | + | |
| 28 | +| 音色名称 | voice_type | 语种 | 支持的情感 | | |
| 29 | +|---------|-----------|------|-----------| | |
| 30 | +| Candice | `en_female_candice_emo_v2_mars_bigtts` | 美式英语 | affectionate, angry, ASMR, chat, excited, happy, neutral, warm | | |
| 31 | +| Serena | `en_female_skye_emo_v2_mars_bigtts` | 美式英语 | affectionate, angry, ASMR, chat, excited, happy, neutral, sad, warm | | |
| 32 | +| Glen | `en_male_glen_emo_v2_mars_bigtts` | 美式英语 | affectionate, angry, ASMR, chat, excited, happy, neutral, sad, warm | | |
| 33 | +| Sylus | `en_male_sylus_emo_v2_mars_bigtts` | 美式英语 | affectionate, angry, ASMR, authoritative, chat, excited, happy, neutral, sad, warm | | |
| 34 | +| Corey | `en_male_corey_emo_v2_mars_bigtts` | 英式英语 | angry, ASMR, authoritative, chat, affectionate, excited, happy, neutral, sad, warm | | |
| 35 | +| Nadia | `en_female_nadia_tips_emo_v2_mars_bigtts` | 英式英语 | affectionate, angry, ASMR, chat, excited, happy, neutral, sad, warm | | |
| 36 | + | |
| 37 | +## 通用中文音色(无多情感,但音质好) | |
| 38 | + | |
| 39 | +| 音色名称 | voice_type | 说明 | | |
| 40 | +|---------|-----------|------| | |
| 41 | +| 湾湾小何 | `zh_female_wanwanxiaohe_moon_bigtts` | 台湾口音女声 | | |
| 42 | +| 幽默大爷 | `ICL_zh_male_youmodaye_tob` | 搞笑老年男声 | | |
| 43 | +| 温柔女神 | `ICL_zh_female_wenrounvshen_239eff5e8ffa_tob` | 温柔女声 | | |
| 44 | +| 机灵小伙 | `ICL_zh_male_shenmi_v1_tob` | 活泼男声 | | |
| 45 | +| 元气甜妹 | `ICL_zh_female_wuxi_tob` | 甜美女声 | | |
| 46 | +| 渊博小叔 | `zh_male_yuanboxiaoshu_moon_bigtts` | 成熟男声 | | |
| 47 | +| 邻家女孩 | `zh_female_linjianvhai_moon_bigtts` | 清新女声 | | |
| 48 | +| 解说小明 | `zh_male_jieshuoxiaoming_moon_bigtts` | 解说风格 | | |
| 49 | +| 奶气萌娃 | `zh_male_naiqimengwa_mars_bigtts` | 儿童声 | | |
| 50 | +| 婆婆 | `zh_female_popo_mars_bigtts` | 老年女声 | | |
| 51 | + | |
| 52 | +## 角色扮演音色(适合特定角色类型) | |
| 53 | + | |
| 54 | +| 音色名称 | voice_type | 风格 | | |
| 55 | +|---------|-----------|------| | |
| 56 | +| 霸道总裁 | `ICL_zh_male_badaozongcai_v1_tob` | 霸气男声 | | |
| 57 | +| 邪魅御姐 | `ICL_zh_female_xiangliangya_v1_tob` | 魅惑女声 | | |
| 58 | +| 油腻大叔 | `ICL_zh_male_you_tob` | 油腻中年男 | | |
| 59 | +| 病弱公子 | `ICL_zh_male_bingruogongzi_tob` | 虚弱男声 | | |
| 60 | +| 纯真少女 | `ICL_zh_female_chunzhenshaonv_e588402fb8ad_tob` | 天真女声 | | |
| 61 | +| 孤傲公子 | `ICL_zh_male_guaogongzi_v1_tob` | 高冷男声 | | |
| 62 | +| 撒娇男友 | `ICL_zh_male_sajiaonanyou_tob` | 撒娇男声 | | |
| 63 | +| 成熟姐姐 | `ICL_zh_female_chengshujiejie_tob` | 成熟女声 | | |
| 64 | +| 傲娇女友 | `ICL_zh_female_aojiaonvyou_tob` | 傲娇女声 | | |
| 65 | +| 贴心女友 | `ICL_zh_female_tiexinnvyou_tob` | 温柔女声 | | |
| 66 | +| 热血少年 | `ICL_zh_male_rexueshaonian_tob` | 热血男声 | | |
| 67 | +| 冷峻上司 | `ICL_zh_male_lengjunshangsi_tob` | 严肃男声 | | |
| 68 | + | |
| 69 | +## IP 仿音(趣味) | |
| 70 | + | |
| 71 | +| 音色名称 | voice_type | | |
| 72 | +|---------|-----------| | |
| 73 | +| 唐僧 | `zh_male_tangseng_mars_bigtts` | | |
| 74 | +| 猪八戒 | `zh_male_zhubajie_mars_bigtts` | | |
| 75 | +| 猴哥 | `zh_male_sunwukong_mars_bigtts` | | |
| 76 | +| 佩奇猪 | `zh_female_peiqi_mars_bigtts` | | |
| 77 | +| 熊二 | `zh_male_xionger_mars_bigtts` | | |
| 78 | +| 鲁班七号 | `zh_male_lubanqihao_mars_bigtts` | | |
| 79 | + | |
| 80 | +## 趣味口音 | |
| 81 | + | |
| 82 | +| 音色名称 | voice_type | 口音 | | |
| 83 | +|---------|-----------|------| | |
| 84 | +| 双节棍小哥 | `zh_male_zhoujielun_emo_v2_mars_bigtts` | 台湾口音 | | |
| 85 | +| 豫州子轩 | `zh_male_yuzhouzixuan_moon_bigtts` | 河南口音 | | |
| 86 | +| 呆萌川妹 | `zh_female_daimengchuanmei_moon_bigtts` | 四川口音 | | |
| 87 | +| 广西远舟 | `zh_male_guangxiyuanzhou_moon_bigtts` | 广西口音 | | |
| 88 | +| 妹坨洁儿 | `zh_female_meituojieer_moon_bigtts` | 长沙口音 | | |
| 89 | + | |
| 90 | +## 通用英文音色 | |
| 91 | + | |
| 92 | +### 美式英语 | |
| 93 | + | |
| 94 | +| 音色名称 | voice_type | | |
| 95 | +|---------|-----------| | |
| 96 | +| Lauren | `en_female_lauren_moon_bigtts` | | |
| 97 | +| Bruce | `en_male_bruce_moon_bigtts` | | |
| 98 | +| Michael | `en_male_michael_moon_bigtts` | | |
| 99 | +| Nara | `en_female_nara_moon_bigtts` | | |
| 100 | +| Adam | `en_male_adam_mars_bigtts` | | |
| 101 | +| Amanda | `en_female_amanda_mars_bigtts` | | |
| 102 | +| Jackson | `en_male_jackson_mars_bigtts` | | |
| 103 | +| Lucas | `zh_male_M100_conversation_wvae_bigtts` | | |
| 104 | +| Luna | `en_female_sarah_new_conversation_wvae_bigtts` | | |
| 105 | + | |
| 106 | +### 英式英语 | |
| 107 | + | |
| 108 | +| 音色名称 | voice_type | | |
| 109 | +|---------|-----------| | |
| 110 | +| Emily | `en_female_emily_mars_bigtts` | | |
| 111 | +| Smith | `en_male_smith_mars_bigtts` | | |
| 112 | +| Anna | `en_female_anna_mars_bigtts` | | |
| 113 | +| Dave | `en_male_dave_moon_bigtts` | | |
| 114 | + | |
| 115 | +## 情感参数速查 | |
| 116 | + | |
| 117 | +### 中文情感 | |
| 118 | +``` | |
| 119 | +happy, sad, angry, surprised, fear, hate, excited, coldness, neutral, | |
| 120 | +depressed, lovey-dovey, shy, comfort, tension, tender, storytelling, | |
| 121 | +radio, magnetic, advertising, vocal-fry, ASMR, news, entertainment, dialect | |
| 122 | +``` | |
| 123 | + | |
| 124 | +### 英文情感 | |
| 125 | +``` | |
| 126 | +neutral, happy, angry, sad, excited, chat, ASMR, warm, affectionate, authoritative | |
| 127 | +``` | ... | ... |