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 | \ No newline at end of file | 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 | +``` |