From 17e6455eeb4d9d53fd707a706b8f01d9becf597b Mon Sep 17 00:00:00 2001 From: yibo Date: Thu, 26 Mar 2026 15:44:45 +0800 Subject: [PATCH] docs: add create-drama documentation and scripts --- docs/create-drama/.env | 17 +++++++++++++++++ docs/create-drama/SKILL.md | 242 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ docs/create-drama/generate-images.js | 391 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ docs/create-drama/insert-drama.js | 347 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ docs/create-drama/reference.md | 127 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 1124 insertions(+), 0 deletions(-) create mode 100644 docs/create-drama/.env create mode 100644 docs/create-drama/SKILL.md create mode 100644 docs/create-drama/generate-images.js create mode 100644 docs/create-drama/insert-drama.js create mode 100644 docs/create-drama/reference.md diff --git a/docs/create-drama/.env b/docs/create-drama/.env new file mode 100644 index 0000000..a52e434 --- /dev/null +++ b/docs/create-drama/.env @@ -0,0 +1,17 @@ +# === MySQL 数据库 === +DB_HOST=rm-2zeel4sa31na88gd4qo.rwlb.rds.aliyuncs.com +DB_PORT=3306 +DB_USER=lemon +DB_PASSWORD=Lemon@123456 +DB_NAME=shadiao_drama + +# === 阿里云 OSS(图片上传) === +OSS_REGION=oss-cn-beijing +OSS_ACCESS_KEY_ID=LTAI5tGtipK3GrbTwZm7JjTS +OSS_ACCESS_KEY_SECRET=r6axVSGVyIzL3Ty2T5pV1SVFeiir3t +OSS_BUCKET=shadiao-drama +OSS_ENDPOINT=https://oss-cn-beijing.aliyuncs.com + +# === 万界生图 API(Jimeng + Nano Banana Pro) === +WANJIE_API_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3NzA5NjIyNzgsImtleSI6IjVLNzVaOFROQzlGNEhNMzdQOVk3In0.2axgWHccUPjL3cQr2-1jAGLZ4hVAcTbn-LU78CMMex4 +USER_ID=1 \ No newline at end of file diff --git a/docs/create-drama/SKILL.md b/docs/create-drama/SKILL.md new file mode 100644 index 0000000..c4c8a35 --- /dev/null +++ b/docs/create-drama/SKILL.md @@ -0,0 +1,242 @@ +--- +description: 从素材(新闻、故事、段子等)自动生成 classic 互动剧本(含角色图、场景图)并写入数据库 +--- + +# create-drama — 从素材自动生成完整 classic 互动剧本 + +根据用户提供的内容素材(新闻、故事、段子、历史事件等),生成一个完整的 classic 类型互动剧本,包括: +- 剧本数据(角色、台词、表情、动作) +- 角色立绘图(AI 生图 → 去背景 → 上传 OSS) +- 背景图和封面图(AI 生图 → 上传 OSS) +- 写入数据库 + +## 输入 + +用户会在 `$ARGUMENTS` 中提供内容素材。素材可能是: +- 一段新闻报道 +- 一个故事/段子 +- 一个历史事件描述 +- 一个创意想法 +- 一个文件路径(需要先读取文件内容) + +## 执行步骤 + +### 第 1 步:分析素材 + +仔细阅读用户提供的素材内容。如果是文件路径,先用 Read 工具读取文件。 + +### 第 2 步:生成完整剧本 JSON + +根据素材内容,你自己(Claude)直接生成剧本数据 JSON。**不要调用任何外部 LLM API。** + +生成的 JSON 必须严格遵循以下格式: + +```json +{ + "name": "剧本名称(简短,2-6个字)", + "title": "显示标题(可以带 emoji,稍微长一点)", + "subtitle": "— 副标题 —", + "description": "剧本简介(一句话概括)", + "systemPrompt": "用于后续 AI 续写的系统提示词(详见下方规则)", + "isPublic": false, + "userId": 1, + "tags": "标签1,标签2,标签3", + "characters": [ + { + "name": "角色名", + "gender": "male 或 female", + "voiceId": "从音色列表选择", + "imagePrompt": "角色立绘生图 prompt(详见下方规则)" + } + ], + "script": [ + { + "character": "角色名(必须在 characters 中定义过)", + "dialogue": "台词内容", + "avatar": "表情文件名", + "action": "动画效果", + "position": "left 或 right" + } + ], + "backgroundPrompt": "背景图生图 prompt", + "coverPrompt": "封面图生图 prompt" +} +``` + +### 生成规则 + +#### 角色 (characters) + +- 数量:2-5 个 +- 每个角色必须有 name、gender、voiceId、imagePrompt +- voiceId 选择:参考 [reference.md](reference.md) 中的完整音色列表 + - **优先使用多情感音色**(支持 happy/sad/angry 等情绪切换,效果更好) + - 根据角色性格、年龄、性别匹配合适的音色 + - 英文角色使用英文多情感音色(Candice, Serena, Glen, Sylus, Corey, Nadia) + - 搞笑角色可用 IP 仿音(唐僧、猪八戒等)或趣味口音 + - 常用推荐: + - 男主角: `zh_male_yangguangqingnian_emo_v2_mars_bigtts`(阳光青年)或 `zh_male_junlangnanyou_emo_v2_mars_bigtts`(俊朗男友) + - 女主角: `zh_female_roumeinvyou_emo_v2_mars_bigtts`(柔美女友)或 `zh_female_gaolengyujie_emo_v2_mars_bigtts`(高冷御姐) + - 反派/严肃: `zh_male_lengkugege_emo_v2_mars_bigtts`(冷酷哥哥)或 `zh_male_aojiaobazong_emo_v2_mars_bigtts`(傲娇霸总) + - 搞笑: `ICL_zh_male_youmodaye_tob`(幽默大爷)或 `zh_male_jingqiangkanye_emo_mars_bigtts`(京腔侃爷) + - 老年: `zh_female_popo_mars_bigtts`(婆婆)或 `ICL_zh_male_youmodaye_tob`(幽默大爷) + - 英文男: `en_male_sylus_emo_v2_mars_bigtts`(Sylus)或 `en_male_glen_emo_v2_mars_bigtts`(Glen) + - 英文女: `en_female_candice_emo_v2_mars_bigtts`(Candice)或 `en_female_skye_emo_v2_mars_bigtts`(Serena) + +#### 角色立绘 imagePrompt + +每个角色必须生成 imagePrompt,遵循以下模板: + +``` +生成一张**[人物名字或身份]**的全身站立画像。 +核心风格:沙雕动漫风格(meme style),2D简笔画,线条清晰粗犷,色彩平涂,带有幽默滑稽感,略微的大头小身体比例。 +脸部要求(最重要):脸部必须是纯肉色的完全空白,绝对不要画眼睛、鼻子、嘴巴或眉毛等任何五官,保留一个干净完整的面部轮廓,方便后期添加表情包。 +服装与动作:角色穿着**[根据角色描述填写服装特征],手里拿着[根据角色身份推断合适的道具],[一个有趣的站姿或动作]**。 +背景要求:纯色干净背景(如浅蓝灰色),不要任何环境元素或杂物,边缘清晰,方便直接抠图。 +``` + +根据角色具体描述,在 [] 处填入合适内容。 + +#### 背景图 backgroundPrompt + +``` +横版宽屏场景背景图,卡通风格,适合互动剧背景。场景:[根据剧本内容描述场景]。不要人物,色彩鲜明,气氛感强。 +``` + +#### 封面图 coverPrompt + +``` +剧目封面海报。标题:[剧本标题]。展示[角色名]等角色的剪影轮廓,色彩鲜明,有标题感,卡通风格。 +``` + +#### 台词 (script) + +- 数量:10-20 句(根据素材丰富度调整,尽量多一些,让剧情完整) +- avatar 可选值:`normal.png`, `happy.png`, `very_happy.png`, `angry.png`, `sad.png`, `cry.png`, `shock.png`, `smirk.png`, `speechless.png` +- action 可选值: + - `none` — 默认,无动画 + - `shake` — 震动,适合愤怒/惊吓/强调 + - `jump` — 跳跃,适合惊喜/激动 + - `spin` — 旋转,适合混乱/搞笑 + - `zoom-in` — 放大,适合重要台词/特写 +- position: 每个角色应该有固定站位(left/right),2个角色时一左一右,3个以上轮替 +- 台词要生动有趣,符合互动剧的沙雕风格 +- 对话要有戏剧冲突和反转 +- 每句台词不要太长,控制在 30 字以内 + +#### systemPrompt + +- 要详细描述世界观和角色设定 +- 包含语言风格指导(用于后续 AI 续写对话) +- 至少 200 字 + +#### tags + +- 1-3 个标签,逗号分隔 +- 如:`搞笑,职场,反转` + +### 第 3 步:写入临时 JSON 文件 + +将生成的 JSON 写入临时文件 `/tmp/drama_.json` + +### 第 4 步:生成图片并上传 OSS + +找到本 skill 的目录路径(即本文件 SKILL.md 所在目录),执行图片生成脚本: + +```bash +node /generate-images.js /tmp/drama_.json +``` + +该脚本会: +1. 为每个角色生成立绘图(Jimeng → fallback Nano Banana Pro) +2. 自动去背景(rembg) +3. 为剧本生成背景图和封面图 +4. 全部上传到阿里云 OSS +5. 将 URL 写回 JSON 文件(更新 characters[].bodyUrl, coverUrl, backgroundUrl) + +**注意**:该脚本需要 `ali-oss`, `sharp` npm 包以及 `rembg` Python 工具。如果 rembg 未安装,会跳过去背景步骤。 + +### 第 5 步:写入数据库 + +执行入库脚本: + +```bash +node /insert-drama.js /tmp/drama_.json +``` + +脚本会在事务中创建: +- `dramas` 表记录(剧本元数据) +- `drama_characters` 表记录(角色配置 + 身体图 URL + 音色) +- `drama_emotions` 表记录(默认表情映射) +- `drama_scripts` 表记录(台词序列) + +### 第 6 步:清理并输出结果 + +1. 删除临时 JSON 文件 +2. 告知用户: + - 剧本名称和 dramaKey + - 角色列表及音色 + - 台词数量 + - 图片生成结果(成功/失败) + - 简要剧情概述 + +## 配置说明 + +脚本会自动从以下位置加载配置(按优先级): +1. `--env ` 参数指定的 .env 文件 +2. 环境变量 `DRAMA_ENV_PATH` 指定的 .env 文件 +3. skill 目录下的 `.env` 文件 +4. 已有的环境变量 + +所需配置项: + +```env +# 数据库 +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_USER=root +DB_PASSWORD=xxx +DB_NAME=shadiao_drama + +# 阿里云 OSS(图片上传) +OSS_REGION=oss-cn-beijing +OSS_ACCESS_KEY_ID=xxx +OSS_ACCESS_KEY_SECRET=xxx +OSS_BUCKET=xxx +OSS_ENDPOINT=https://oss-cn-beijing.aliyuncs.com + +# 万界生图 API +WANJIE_API_KEY=xxx +``` + +## 注意事项 + +- **默认使用英文**:所有内容(name、title、subtitle、description、systemPrompt、台词、tags、imagePrompt、backgroundPrompt、coverPrompt)默认用英文生成,除非用户明确要求中文 +- 英文内容必须使用英文多情感音色(Candice, Serena, Glen, Sylus, Corey, Nadia) +- 风格偏沙雕搞笑,但要保留素材的核心信息 +- 如果素材是严肃新闻,可以用吐槽/解说风格改编 +- 角色名字要有趣,符合素材主题 +- 如果用户明确要求中文,则使用中文音色 + +## 复用到其他项目 + +整个 `create-drama/` 目录是自包含的,复制到其他项目的 `.claude/skills/` 即可使用。 + +**前置条件**: +1. Node.js 依赖:`npm i -g sequelize mysql2 ali-oss sharp` +2. Python 工具:`pip install rembg`(可选,用于角色图去背景) +3. 在 `create-drama/` 目录下修改 `.env` 配置 + +**目录结构**: +``` +.claude/skills/create-drama/ +├── SKILL.md # Skill 定义(本文件) +├── reference.md # 完整音色参考文档 +├── insert-drama.js # 数据库入库脚本 +├── generate-images.js # 图片生成 + 去背景 + OSS 上传脚本 +└── .env # 数据库 + OSS + 生图 API 配置 +``` + +## Additional Resources + +- 完整音色列表及情感参数:[reference.md](reference.md) diff --git a/docs/create-drama/generate-images.js b/docs/create-drama/generate-images.js new file mode 100644 index 0000000..5e74e94 --- /dev/null +++ b/docs/create-drama/generate-images.js @@ -0,0 +1,391 @@ +#!/usr/bin/env node +/** + * generate-images.js — 为剧本生成角色图、背景图、封面图,并上传到 OSS + * + * 用法: + * node generate-images.js + * + * 输入 JSON 中需要包含: + * - characters[].imagePrompt (角色生图 prompt,由 SKILL.md 指导 Claude 生成) + * - backgroundPrompt (背景图 prompt) + * - coverPrompt (封面图 prompt) + * - dramaKey (用于 OSS 路径) + * + * 输出: 在原 JSON 基础上补充 URL 字段,写回文件 + * - characters[].bodyUrl + * - coverUrl + * - backgroundUrl + * + * 依赖: ali-oss, sharp (可选), rembg (Python, 可选) + */ + +const path = require('path'); +const fs = require('fs'); +const https = require('https'); +const http = require('http'); +const { execSync } = require('child_process'); +const os = require('os'); + +// ---------- 加载 .env ---------- +function loadEnv() { + const envArgIdx = process.argv.indexOf('--env'); + const candidates = [ + envArgIdx !== -1 ? path.resolve(process.argv[envArgIdx + 1]) : null, + process.env.DRAMA_ENV_PATH ? path.resolve(process.env.DRAMA_ENV_PATH) : null, + path.resolve(__dirname, '.env'), + ].filter(Boolean); + + for (const envPath of candidates) { + if (fs.existsSync(envPath)) { + const envContent = fs.readFileSync(envPath, 'utf-8'); + for (const line of envContent.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eqIdx = trimmed.indexOf('='); + if (eqIdx === -1) continue; + const key = trimmed.slice(0, eqIdx).trim(); + const val = trimmed.slice(eqIdx + 1).trim(); + if (!process.env[key]) process.env[key] = val; + } + console.log(`已加载配置: ${envPath}`); + return; + } + } + console.log('未找到 .env 文件,使用环境变量'); +} +loadEnv(); + +// ========== Wanjie 图片生成 API ========== + +const WANJIE_BASE = 'maas-openapi.wanjiedata.com'; + +function wanjieRequest(action, body) { + const apiKey = process.env.WANJIE_API_KEY; + if (!apiKey) throw new Error('缺少 WANJIE_API_KEY 环境变量'); + + const jsonBody = JSON.stringify(body); + return new Promise((resolve, reject) => { + const req = https.request({ + hostname: WANJIE_BASE, + path: `/api/jimeng/v1?Action=${action}&Version=2022-08-31`, + method: 'POST', + headers: { + 'Authorization': apiKey, + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(jsonBody), + }, + }, (res) => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { + try { resolve(JSON.parse(data)); } + catch (err) { reject(new Error(`解析响应失败: ${data.slice(0, 300)}`)); } + }); + }); + req.on('error', reject); + req.write(jsonBody); + req.end(); + }); +} + +/** Jimeng 文生图(异步模式) */ +async function generateImageJimeng(prompt, width = 1024, height = 1024) { + const res = await wanjieRequest('CVSync2AsyncSubmitTask', { + req_key: 'jimeng_t2i_v40', + prompt, width, height, + }); + + if (res.code !== 10000 || !res.data?.task_id) { + throw new Error(`Jimeng 提交失败: ${JSON.stringify(res).slice(0, 300)}`); + } + + const taskId = res.data.task_id; + console.log(` [Jimeng] 任务提交: task_id=${taskId}`); + + // 轮询等待 + const start = Date.now(); + while (Date.now() - start < 120000) { + const poll = await wanjieRequest('CVSync2AsyncGetResult', { + req_key: 'jimeng_t2i_v40', task_id: taskId, + }); + + if (poll.code === 10000 && poll.data?.image_urls?.length > 0) { + const buffer = await downloadUrl(poll.data.image_urls[0]); + return { buffer, mimeType: 'image/png' }; + } + if (poll.code === 10000 && poll.data?.binary_data_base64?.length > 0) { + return { buffer: Buffer.from(poll.data.binary_data_base64[0], 'base64'), mimeType: 'image/png' }; + } + if (poll.code && poll.code !== 10000 && !poll.message?.includes('processing') && !poll.message?.includes('pending')) { + throw new Error(`Jimeng 任务失败: ${JSON.stringify(poll).slice(0, 300)}`); + } + + await sleep(3000); + } + throw new Error(`Jimeng 超时: task_id=${taskId}`); +} + +/** Nano Banana Pro 文生图(同步模式,fallback) */ +async function generateImageNano(prompt) { + const apiKey = process.env.WANJIE_API_KEY; + if (!apiKey) throw new Error('缺少 WANJIE_API_KEY'); + + const url = `https://${WANJIE_BASE}/api/v1beta/models/gemini-3-pro-image-preview:generateContent`; + const body = JSON.stringify({ contents: [{ parts: [{ text: prompt }] }] }); + + for (let attempt = 1; attempt <= 3; attempt++) { + try { + const data = await httpsPost(url, body, { 'Authorization': `Bearer ${apiKey}` }); + const response = JSON.parse(data); + const img = extractImageFromResponse(response); + if (img) return img; + throw new Error('无图片数据'); + } catch (err) { + if (err.statusCode === 429 && attempt < 3) { + console.warn(` [Nano] 429 限流,${3 * attempt}s 后重试...`); + await sleep(3000 * attempt); + continue; + } + throw err; + } + } +} + +function extractImageFromResponse(response) { + const parts = response?.candidates?.[0]?.content?.parts; + if (!parts) return null; + for (const part of parts) { + if (part.inlineData) { + return { buffer: Buffer.from(part.inlineData.data, 'base64'), mimeType: part.inlineData.mimeType || 'image/png' }; + } + if (part.text && part.text.includes('data:image')) { + const match = part.text.match(/data:image\/(png|jpeg);base64,([A-Za-z0-9+/=]+)/); + if (match) return { buffer: Buffer.from(match[2], 'base64'), mimeType: `image/${match[1]}` }; + } + } + return null; +} + +/** 生图:优先 Jimeng,fallback Nano */ +async function generateImage(prompt) { + try { + return await generateImageJimeng(prompt); + } catch (jimengErr) { + console.warn(` [Jimeng] 失败: ${jimengErr.message.slice(0, 80)},fallback Nano...`); + return await generateImageNano(prompt); + } +} + +// ========== 图片处理 ========== + +/** 去背景(rembg) */ +function removeBg(inputBuffer) { + const tmpDir = os.tmpdir(); + const ts = Date.now(); + const inputPath = path.join(tmpDir, `rembg_in_${ts}.png`); + const outputPath = path.join(tmpDir, `rembg_out_${ts}.png`); + + try { + fs.writeFileSync(inputPath, inputBuffer); + execSync(`rembg i "${inputPath}" "${outputPath}"`, { timeout: 60000 }); + const result = fs.readFileSync(outputPath); + console.log(' [rembg] 去背景成功'); + + // 尝试 sharp 裁剪 + try { + const sharp = require('sharp'); + // sharp 操作是 async,但我们在 sync context 用 execSync 调 sharp + // 改用 async + return trimImage(result); + } catch { + return result; + } + } catch (err) { + console.warn(` [rembg] 失败(${err.message.slice(0, 60)}),返回原图`); + return inputBuffer; + } finally { + try { fs.unlinkSync(inputPath); } catch { } + try { fs.unlinkSync(outputPath); } catch { } + } +} + +async function trimImage(buffer) { + try { + const sharp = require('sharp'); + const trimmed = await sharp(buffer).trim().png().toBuffer(); + const meta = await sharp(trimmed).metadata(); + const padX = Math.round(meta.width * 0.05); + const padY = Math.round(meta.height * 0.05); + return await sharp(trimmed) + .extend({ top: padY, bottom: padY, left: padX, right: padX, background: { r: 0, g: 0, b: 0, alpha: 0 } }) + .png().toBuffer(); + } catch { + return buffer; + } +} + +// ========== OSS 上传 ========== + +function getOSSClient() { + const OSS = require('ali-oss'); + return new OSS({ + region: process.env.OSS_REGION, + accessKeyId: process.env.OSS_ACCESS_KEY_ID, + accessKeySecret: process.env.OSS_ACCESS_KEY_SECRET, + bucket: process.env.OSS_BUCKET, + }); +} + +function getPublicUrl(key) { + const endpoint = process.env.OSS_ENDPOINT || `https://${process.env.OSS_REGION}.aliyuncs.com`; + const bucket = process.env.OSS_BUCKET; + return `https://${bucket}.${endpoint.replace('https://', '')}/${key}`; +} + +async function uploadToOSS(ossKey, buffer, contentType = 'image/png') { + const client = getOSSClient(); + const result = await client.put(ossKey, buffer, { headers: { 'Content-Type': contentType } }); + return getPublicUrl(result.name); +} + +// ========== 工具函数 ========== + +function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } + +function httpsPost(url, body, extraHeaders = {}) { + return new Promise((resolve, reject) => { + const urlObj = new URL(url); + const req = https.request({ + hostname: urlObj.hostname, + path: urlObj.pathname + urlObj.search, + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body), ...extraHeaders }, + }, (res) => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { + if (res.statusCode === 429) { const e = new Error('429'); e.statusCode = 429; reject(e); return; } + resolve(data); + }); + }); + req.on('error', reject); + req.write(body); + req.end(); + }); +} + +function downloadUrl(url) { + return new Promise((resolve, reject) => { + const protocol = url.startsWith('https') ? https : http; + protocol.get(url, (res) => { + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + return downloadUrl(res.headers.location).then(resolve).catch(reject); + } + const chunks = []; + res.on('data', chunk => chunks.push(chunk)); + res.on('end', () => resolve(Buffer.concat(chunks))); + res.on('error', reject); + }).on('error', reject); + }); +} + +// ========== 主逻辑 ========== + +async function main() { + const filePath = process.argv.find(a => a.endsWith('.json')); + if (!filePath) { + console.error('用法: node generate-images.js '); + process.exit(1); + } + + const absPath = path.resolve(filePath); + const data = JSON.parse(fs.readFileSync(absPath, 'utf-8')); + const dramaKey = data.dramaKey || `drama_${Date.now().toString(36)}`; + data.dramaKey = dramaKey; + + // 检查 OSS 配置 + const ossOk = process.env.OSS_REGION && process.env.OSS_ACCESS_KEY_ID && process.env.OSS_ACCESS_KEY_SECRET && process.env.OSS_BUCKET; + if (!ossOk) { + console.error('OSS 配置不完整,请检查 .env 中的 OSS_REGION, OSS_ACCESS_KEY_ID, OSS_ACCESS_KEY_SECRET, OSS_BUCKET'); + process.exit(1); + } + + // 检查生图 API 配置 + if (!process.env.WANJIE_API_KEY) { + console.error('缺少 WANJIE_API_KEY'); + process.exit(1); + } + + const characters = data.characters || []; + const total = characters.length + 2; // chars + background + cover + let completed = 0; + const tick = (label) => { completed++; console.log(`[${completed}/${total}] ${label} 完成`); }; + + // 并行处理所有图片 + const tasks = []; + + // 角色图 + for (const char of characters) { + tasks.push((async () => { + const prompt = char.imagePrompt || `全身立绘,纯色背景,沙雕动漫风格。角色:${char.name}。脸部空白无五官。`; + console.log(`\n生成角色图: ${char.name}`); + console.log(` prompt: ${prompt.slice(0, 80)}...`); + + const { buffer } = await generateImage(prompt); + + // 去背景 + console.log(` 正在去背景...`); + const processed = await removeBg(buffer); + + // 上传 OSS + const safeName = Buffer.from(char.name).toString('hex').slice(0, 16); + const ossKey = `dramas/${dramaKey}/characters/${safeName}.png`; + const url = await uploadToOSS(ossKey, processed); + char.bodyUrl = url; + console.log(` 上传完成: ${url}`); + tick(char.name); + })()); + } + + // 背景图 + tasks.push((async () => { + const prompt = data.backgroundPrompt || `横版场景背景图,宽屏比例,场景:${data.description || data.name},卡通风格,无人物。`; + console.log(`\n生成背景图`); + console.log(` prompt: ${prompt.slice(0, 80)}...`); + + const { buffer } = await generateImage(prompt); + const ossKey = `dramas/${dramaKey}/background.png`; + const url = await uploadToOSS(ossKey, buffer); + data.backgroundUrl = url; + console.log(` 上传完成: ${url}`); + tick('背景图'); + })()); + + // 封面图 + tasks.push((async () => { + const prompt = data.coverPrompt || `剧目封面海报,标题:${data.title || data.name},色彩鲜明。`; + console.log(`\n生成封面图`); + console.log(` prompt: ${prompt.slice(0, 80)}...`); + + const { buffer } = await generateImage(prompt); + const ossKey = `dramas/${dramaKey}/cover.png`; + const url = await uploadToOSS(ossKey, buffer); + data.coverUrl = url; + console.log(` 上传完成: ${url}`); + tick('封面图'); + })()); + + await Promise.allSettled(tasks); + + // 写回文件 + fs.writeFileSync(absPath, JSON.stringify(data, null, 2), 'utf-8'); + console.log(`\n图片生成完成,已更新 JSON 文件: ${absPath}`); + console.log(` 角色图: ${characters.filter(c => c.bodyUrl).length}/${characters.length}`); + console.log(` 背景图: ${data.backgroundUrl ? '✓' : '✗'}`); + console.log(` 封面图: ${data.coverUrl ? '✓' : '✗'}`); +} + +main().catch(err => { + console.error('执行失败:', err); + process.exit(1); +}); diff --git a/docs/create-drama/insert-drama.js b/docs/create-drama/insert-drama.js new file mode 100644 index 0000000..d1c840d --- /dev/null +++ b/docs/create-drama/insert-drama.js @@ -0,0 +1,347 @@ +#!/usr/bin/env node +/** + * insert-drama.js — 独立脚本:将 classic 类型剧本数据插入数据库 + * + * 用法: + * node insert-drama.js + * echo '{"name":...}' | node insert-drama.js --stdin + * + * JSON 格式: + * { + * "name": "剧本名称", + * "title": "显示标题(可带 emoji)", + * "subtitle": "副标题", + * "description": "剧本简介", + * "systemPrompt": "用于 AI 续写的系统提示词", + * "isPublic": true, + * "userId": 1, + * "characters": [ + * { "name": "角色名", "gender": "male", "voiceId": "zh_male_lengkugege_emo_v2_mars_bigtts" } + * ], + * "script": [ + * { "character": "角色名", "dialogue": "台词", "avatar": "happy.png", "action": "none", "position": "left" } + * ] + * } + */ + +const path = require('path'); +const fs = require('fs'); + +// 加载 .env 文件(优先级:--env 参数 > 环境变量 DRAMA_ENV_PATH > 脚本同目录 .env) +function loadEnv() { + const envArgIdx = process.argv.indexOf('--env'); + const candidates = [ + envArgIdx !== -1 ? path.resolve(process.argv[envArgIdx + 1]) : null, + process.env.DRAMA_ENV_PATH ? path.resolve(process.env.DRAMA_ENV_PATH) : null, + path.resolve(__dirname, '.env'), + ].filter(Boolean); + + for (const envPath of candidates) { + if (fs.existsSync(envPath)) { + const envContent = fs.readFileSync(envPath, 'utf-8'); + for (const line of envContent.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eqIdx = trimmed.indexOf('='); + if (eqIdx === -1) continue; + const key = trimmed.slice(0, eqIdx).trim(); + const val = trimmed.slice(eqIdx + 1).trim(); + if (!process.env[key]) process.env[key] = val; + } + console.log(`已加载配置: ${envPath}`); + return; + } + } + console.log('未找到 .env 文件,使用环境变量或默认值'); +} +loadEnv(); + +const { Sequelize, DataTypes } = require('sequelize'); + +// ---------- DB 连接 ---------- +const sequelize = new Sequelize( + process.env.DB_NAME || 'shadiao_drama', + process.env.DB_USER || 'root', + process.env.DB_PASSWORD || '', + { + host: process.env.DB_HOST || '127.0.0.1', + port: parseInt(process.env.DB_PORT || '3306', 10), + dialect: 'mysql', + logging: false, + timezone: '+08:00', + define: { charset: 'utf8mb4', collate: 'utf8mb4_unicode_ci' }, + } +); + +// ---------- Model 定义(内联,避免依赖项目代码) ---------- +const Drama = sequelize.define('Drama', { + id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true }, + dramaKey: { type: DataTypes.STRING(50), allowNull: false, unique: true, field: 'drama_key' }, + name: { type: DataTypes.STRING(100), allowNull: false }, + title: { type: DataTypes.STRING(100), allowNull: true }, + subtitle: { type: DataTypes.STRING(200), allowNull: true }, + description: { type: DataTypes.TEXT, allowNull: true }, + coverUrl: { type: DataTypes.STRING(500), allowNull: true, field: 'cover_url' }, + backgroundUrl: { type: DataTypes.STRING(500), allowNull: true, field: 'background_url' }, + systemPrompt: { type: DataTypes.TEXT, allowNull: true, field: 'system_prompt' }, + status: { type: DataTypes.TINYINT, defaultValue: 1 }, + userId: { type: DataTypes.INTEGER, allowNull: true, field: 'user_id' }, + isPublic: { type: DataTypes.BOOLEAN, defaultValue: true, field: 'is_public' }, + type: { type: DataTypes.STRING(30), defaultValue: 'classic' }, + emojiPack: { type: DataTypes.STRING(50), defaultValue: 'default', field: 'emoji_pack' }, + vrmConfig: { type: DataTypes.TEXT, allowNull: true, field: 'vrm_config' }, + tags: { type: DataTypes.STRING(255), allowNull: true }, + score: { type: DataTypes.DECIMAL(10, 2), defaultValue: 0 }, +}, { tableName: 'dramas', timestamps: true, underscored: true }); + +const DramaCharacter = sequelize.define('DramaCharacter', { + id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true }, + dramaId: { type: DataTypes.INTEGER, allowNull: false, field: 'drama_id' }, + characterName: { type: DataTypes.STRING(50), allowNull: false, field: 'character_name' }, + bodyUrl: { type: DataTypes.STRING(500), allowNull: true, field: 'body_url' }, + faceOffsetTop: { type: DataTypes.STRING(20), allowNull: true, field: 'face_offset_top' }, + faceOffsetLeft: { type: DataTypes.STRING(20), allowNull: true, field: 'face_offset_left' }, + faceOffsetWidth: { type: DataTypes.STRING(20), allowNull: true, field: 'face_offset_width' }, + voiceId: { type: DataTypes.STRING(100), allowNull: true, field: 'voice_id' }, + isDefault: { type: DataTypes.TINYINT, defaultValue: 0, field: 'is_default' }, +}, { tableName: 'drama_characters', timestamps: false, underscored: true }); + +const DramaEmotion = sequelize.define('DramaEmotion', { + id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true }, + dramaId: { type: DataTypes.INTEGER, allowNull: false, field: 'drama_id' }, + avatarKey: { type: DataTypes.STRING(50), allowNull: false, field: 'avatar_key' }, + faceUrl: { type: DataTypes.STRING(500), allowNull: true, field: 'face_url' }, + emotionName: { type: DataTypes.STRING(30), allowNull: true, field: 'emotion_name' }, + emotionScale: { type: DataTypes.INTEGER, defaultValue: 3, field: 'emotion_scale' }, + isDefault: { type: DataTypes.TINYINT, defaultValue: 0, field: 'is_default' }, + characterName: { type: DataTypes.STRING(50), allowNull: true, field: 'character_name' }, +}, { tableName: 'drama_emotions', timestamps: false, underscored: true }); + +const DramaScript = sequelize.define('DramaScript', { + id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true }, + dramaId: { type: DataTypes.INTEGER, allowNull: false, field: 'drama_id' }, + seq: { type: DataTypes.INTEGER, allowNull: false }, + characterName: { type: DataTypes.STRING(50), allowNull: false, field: 'character_name' }, + avatar: { type: DataTypes.STRING(50), allowNull: true }, + dialogue: { type: DataTypes.TEXT, allowNull: false }, + action: { type: DataTypes.STRING(30), allowNull: true }, + position: { type: DataTypes.STRING(10), allowNull: true }, + sfx: { type: DataTypes.STRING(100), allowNull: true }, + audio: { type: DataTypes.STRING(500), allowNull: true }, +}, { tableName: 'drama_scripts', timestamps: false, underscored: true }); + +// ---------- 默认表情配置 ---------- +const DEFAULT_EMOJI_BASE = 'https://shadiao-drama.oss-cn-beijing.aliyuncs.com/dramas/zhenhuan_chuan/emojis'; +const DEFAULT_EMOJIS = [ + { key: 'normal.png', name: '正常', emotion: 'neutral', scale: 3 }, + { key: 'happy.png', name: '开心', emotion: 'happy', scale: 4 }, + { key: 'very_happy.png', name: '非常开心', emotion: 'happy', scale: 5 }, + { key: 'angry.png', name: '生气', emotion: 'angry', scale: 4 }, + { key: 'sad.png', name: '伤心', emotion: 'sad', scale: 4 }, + { key: 'cry.png', name: '哭', emotion: 'sad', scale: 5 }, + { key: 'shock.png', name: '惊讶', emotion: 'surprised', scale: 5 }, + { key: 'smirk.png', name: '撇嘴', emotion: 'happy', scale: 3 }, + { key: 'speechless.png', name: '无语', emotion: 'neutral', scale: 2 }, +]; + +// ---------- 音色情绪兼容映射 ---------- +const VOICE_EMOTIONS = { + 'zh_male_lengkugege_emo_v2_mars_bigtts': ['angry','coldness','fear','happy','hate','neutral','sad','depressed'], + 'zh_female_tianxinxiaomei_emo_v2_mars_bigtts': ['sad','fear','hate','neutral'], + 'zh_female_gaolengyujie_emo_v2_mars_bigtts': ['happy','sad','angry','surprised','fear','hate','excited','coldness','neutral'], + 'zh_male_aojiaobazong_emo_v2_mars_bigtts': ['neutral','happy','angry','hate'], + 'zh_male_guangzhoudege_emo_mars_bigtts': ['angry','fear','neutral'], + 'zh_male_jingqiangkanye_emo_mars_bigtts': ['happy','angry','surprised','hate','neutral'], + 'zh_female_linjuayi_emo_v2_mars_bigtts': ['neutral','angry','coldness','depressed','surprised'], + 'zh_male_yourougongzi_emo_v2_mars_bigtts': ['happy','angry','fear','hate','excited','neutral','depressed'], + 'zh_male_ruyayichen_emo_v2_mars_bigtts': ['happy','sad','angry','fear','excited','coldness','neutral'], + 'zh_male_junlangnanyou_emo_v2_mars_bigtts': ['happy','sad','angry','surprised','fear','neutral'], + 'zh_male_beijingxiaoye_emo_v2_mars_bigtts': ['angry','surprised','fear','excited','coldness','neutral'], + 'zh_female_roumeinvyou_emo_v2_mars_bigtts': ['happy','sad','angry','surprised','fear','hate','excited','coldness','neutral'], + 'zh_male_yangguangqingnian_emo_v2_mars_bigtts': ['happy','sad','angry','fear','excited','coldness','neutral'], + 'zh_female_meilinvyou_emo_v2_mars_bigtts': ['sad','fear','neutral'], + 'zh_female_shuangkuaisisi_emo_v2_mars_bigtts': ['happy','sad','angry','surprised','excited','coldness','neutral'], + 'zh_male_shenyeboke_emo_v2_mars_bigtts': ['surprised','sad','neutral','hate','happy','fear','excited','depressed','coldness','angry'], + 'zh_male_zhoujielun_emo_v2_mars_bigtts': ['neutral'], +}; + +function validateEmotion(speaker, emotion) { + if (!emotion || emotion === 'neutral') return emotion; + const supported = VOICE_EMOTIONS[speaker]; + if (!supported) return null; + return supported.includes(emotion) ? emotion : 'neutral'; +} + +// ---------- 构建默认表情记录 ---------- +function buildEmotionRecords(dramaId, characters, defaultVoice) { + const records = []; + const charVoiceMap = {}; + if (characters) { + for (const char of characters) { + if (char.voiceId) charVoiceMap[char.name] = char.voiceId; + } + } + + // 共享默认表情 + for (const emo of DEFAULT_EMOJIS) { + const validEmotion = validateEmotion(defaultVoice, emo.emotion) || 'neutral'; + records.push({ + dramaId, + avatarKey: emo.key, + faceUrl: `${DEFAULT_EMOJI_BASE}/${encodeURIComponent(emo.name)}.png`, + emotionName: validEmotion, + emotionScale: emo.scale, + isDefault: 0, + characterName: null, + }); + } + records.push({ + dramaId, + avatarKey: '_default', + faceUrl: `${DEFAULT_EMOJI_BASE}/${encodeURIComponent('正常')}.png`, + emotionName: 'neutral', + emotionScale: 3, + isDefault: 1, + characterName: null, + }); + + return records; +} + +// ---------- 主逻辑 ---------- +async function main() { + let jsonStr; + + if (process.argv.includes('--stdin')) { + // 从 stdin 读取 + jsonStr = ''; + for await (const chunk of process.stdin) { + jsonStr += chunk; + } + } else if (process.argv[2]) { + // 从文件读取 + const filePath = path.resolve(process.argv[2]); + if (!fs.existsSync(filePath)) { + console.error(`文件不存在: ${filePath}`); + process.exit(1); + } + jsonStr = fs.readFileSync(filePath, 'utf-8'); + } else { + console.error('用法: node insert-drama.js 或 node insert-drama.js --stdin'); + process.exit(1); + } + + let data; + try { + data = JSON.parse(jsonStr); + } catch (e) { + console.error('JSON 解析失败:', e.message); + process.exit(1); + } + + const { name, title, subtitle, description, systemPrompt, isPublic, userId, characters, script, tags } = data; + + if (!name || !script || !Array.isArray(script) || script.length === 0) { + console.error('缺少必要字段: name, script'); + process.exit(1); + } + if (!characters || !Array.isArray(characters) || characters.length === 0) { + console.error('缺少必要字段: characters'); + process.exit(1); + } + + // 连接数据库 + await sequelize.authenticate(); + console.log('数据库连接成功'); + + const t = await sequelize.transaction(); + + try { + const dramaKey = `user_${userId || 1}_${Date.now().toString(36)}`; + + // 1. 创建 Drama 记录 + const drama = await Drama.create({ + dramaKey, + name, + title: title || name, + subtitle: subtitle || '', + description: description || '', + coverUrl: data.coverUrl || '', + backgroundUrl: data.backgroundUrl || '', + systemPrompt: systemPrompt || '', + status: 1, + userId: userId || null, + isPublic: isPublic !== false, + type: 'classic', + emojiPack: 'default', + tags: tags || null, + }, { transaction: t }); + + // 2. 创建角色记录 + const charRecords = characters.map(char => ({ + dramaId: drama.id, + characterName: char.name, + bodyUrl: char.bodyUrl || '', + faceOffsetTop: char.faceOffset?.top || '8%', + faceOffsetLeft: char.faceOffset?.left || '35%', + faceOffsetWidth: char.faceOffset?.width || '30%', + voiceId: char.voiceId || '', + isDefault: char.isDefault ? 1 : 0, + })); + // 添加 _default 角色 + const firstChar = characters[0]; + charRecords.push({ + dramaId: drama.id, + characterName: '_default', + bodyUrl: firstChar.bodyUrl || '', + faceOffsetTop: firstChar.faceOffset?.top || '8%', + faceOffsetLeft: firstChar.faceOffset?.left || '35%', + faceOffsetWidth: firstChar.faceOffset?.width || '30%', + voiceId: firstChar.voiceId || '', + isDefault: 1, + }); + await DramaCharacter.bulkCreate(charRecords, { transaction: t }); + + // 3. 创建表情记录 + const defaultVoice = characters[0]?.voiceId || ''; + const emotionRecords = buildEmotionRecords(drama.id, characters, defaultVoice); + await DramaEmotion.bulkCreate(emotionRecords, { transaction: t }); + + // 4. 创建台词记录 + const scriptRecords = script.map((line, i) => ({ + dramaId: drama.id, + seq: i, + characterName: line.character, + avatar: line.avatar || 'normal.png', + dialogue: line.dialogue, + action: line.action || 'none', + position: line.position || 'left', + sfx: line.sfx || null, + audio: null, + })); + await DramaScript.bulkCreate(scriptRecords, { transaction: t }); + + await t.commit(); + + console.log(`\n剧本创建成功!`); + console.log(` ID: ${drama.id}`); + console.log(` Key: ${dramaKey}`); + console.log(` 名称: ${name}`); + console.log(` 角色数: ${characters.length}`); + console.log(` 台词数: ${script.length}`); + + // 输出 JSON 结果供调用方解析 + console.log(`\n__RESULT__${JSON.stringify({ id: drama.id, dramaKey, name })}__END__`); + } catch (err) { + await t.rollback(); + console.error('插入失败:', err.message); + process.exit(1); + } finally { + await sequelize.close(); + } +} + +main().catch(err => { + console.error('执行失败:', err); + process.exit(1); +}); diff --git a/docs/create-drama/reference.md b/docs/create-drama/reference.md new file mode 100644 index 0000000..1f040aa --- /dev/null +++ b/docs/create-drama/reference.md @@ -0,0 +1,127 @@ +# 音色参考文档 + +## 多情感音色(推荐优先使用) + +### 中文多情感 + +| 音色名称 | voice_type | 支持的情感 | +|---------|-----------|-----------| +| 冷酷哥哥 | `zh_male_lengkugege_emo_v2_mars_bigtts` | angry, coldness, fear, happy, hate, neutral, sad, depressed | +| 甜心小美 | `zh_female_tianxinxiaomei_emo_v2_mars_bigtts` | sad, fear, hate, neutral | +| 高冷御姐 | `zh_female_gaolengyujie_emo_v2_mars_bigtts` | happy, sad, angry, surprised, fear, hate, excited, coldness, neutral | +| 傲娇霸总 | `zh_male_aojiaobazong_emo_v2_mars_bigtts` | neutral, happy, angry, hate | +| 广州德哥 | `zh_male_guangzhoudege_emo_mars_bigtts` | angry, fear, neutral | +| 京腔侃爷 | `zh_male_jingqiangkanye_emo_mars_bigtts` | happy, angry, surprised, hate, neutral | +| 邻居阿姨 | `zh_female_linjuayi_emo_v2_mars_bigtts` | neutral, angry, coldness, depressed, surprised | +| 优柔公子 | `zh_male_yourougongzi_emo_v2_mars_bigtts` | happy, angry, fear, hate, excited, neutral, depressed | +| 儒雅男友 | `zh_male_ruyayichen_emo_v2_mars_bigtts` | happy, sad, angry, fear, excited, coldness, neutral | +| 俊朗男友 | `zh_male_junlangnanyou_emo_v2_mars_bigtts` | happy, sad, angry, surprised, fear, neutral | +| 北京小爷 | `zh_male_beijingxiaoye_emo_v2_mars_bigtts` | angry, surprised, fear, excited, coldness, neutral | +| 柔美女友 | `zh_female_roumeinvyou_emo_v2_mars_bigtts` | happy, sad, angry, surprised, fear, hate, excited, coldness, neutral | +| 阳光青年 | `zh_male_yangguangqingnian_emo_v2_mars_bigtts` | happy, sad, angry, fear, excited, coldness, neutral | +| 魅力女友 | `zh_female_meilinvyou_emo_v2_mars_bigtts` | sad, fear, neutral | +| 爽快思思 | `zh_female_shuangkuaisisi_emo_v2_mars_bigtts` | happy, sad, angry, surprised, excited, coldness, neutral | +| 深夜播客 | `zh_male_shenyeboke_emo_v2_mars_bigtts` | surprised, sad, neutral, hate, happy, fear, excited, depressed, coldness, angry | + +### 英文多情感 + +| 音色名称 | voice_type | 语种 | 支持的情感 | +|---------|-----------|------|-----------| +| Candice | `en_female_candice_emo_v2_mars_bigtts` | 美式英语 | affectionate, angry, ASMR, chat, excited, happy, neutral, warm | +| Serena | `en_female_skye_emo_v2_mars_bigtts` | 美式英语 | affectionate, angry, ASMR, chat, excited, happy, neutral, sad, warm | +| Glen | `en_male_glen_emo_v2_mars_bigtts` | 美式英语 | affectionate, angry, ASMR, chat, excited, happy, neutral, sad, warm | +| Sylus | `en_male_sylus_emo_v2_mars_bigtts` | 美式英语 | affectionate, angry, ASMR, authoritative, chat, excited, happy, neutral, sad, warm | +| Corey | `en_male_corey_emo_v2_mars_bigtts` | 英式英语 | angry, ASMR, authoritative, chat, affectionate, excited, happy, neutral, sad, warm | +| Nadia | `en_female_nadia_tips_emo_v2_mars_bigtts` | 英式英语 | affectionate, angry, ASMR, chat, excited, happy, neutral, sad, warm | + +## 通用中文音色(无多情感,但音质好) + +| 音色名称 | voice_type | 说明 | +|---------|-----------|------| +| 湾湾小何 | `zh_female_wanwanxiaohe_moon_bigtts` | 台湾口音女声 | +| 幽默大爷 | `ICL_zh_male_youmodaye_tob` | 搞笑老年男声 | +| 温柔女神 | `ICL_zh_female_wenrounvshen_239eff5e8ffa_tob` | 温柔女声 | +| 机灵小伙 | `ICL_zh_male_shenmi_v1_tob` | 活泼男声 | +| 元气甜妹 | `ICL_zh_female_wuxi_tob` | 甜美女声 | +| 渊博小叔 | `zh_male_yuanboxiaoshu_moon_bigtts` | 成熟男声 | +| 邻家女孩 | `zh_female_linjianvhai_moon_bigtts` | 清新女声 | +| 解说小明 | `zh_male_jieshuoxiaoming_moon_bigtts` | 解说风格 | +| 奶气萌娃 | `zh_male_naiqimengwa_mars_bigtts` | 儿童声 | +| 婆婆 | `zh_female_popo_mars_bigtts` | 老年女声 | + +## 角色扮演音色(适合特定角色类型) + +| 音色名称 | voice_type | 风格 | +|---------|-----------|------| +| 霸道总裁 | `ICL_zh_male_badaozongcai_v1_tob` | 霸气男声 | +| 邪魅御姐 | `ICL_zh_female_xiangliangya_v1_tob` | 魅惑女声 | +| 油腻大叔 | `ICL_zh_male_you_tob` | 油腻中年男 | +| 病弱公子 | `ICL_zh_male_bingruogongzi_tob` | 虚弱男声 | +| 纯真少女 | `ICL_zh_female_chunzhenshaonv_e588402fb8ad_tob` | 天真女声 | +| 孤傲公子 | `ICL_zh_male_guaogongzi_v1_tob` | 高冷男声 | +| 撒娇男友 | `ICL_zh_male_sajiaonanyou_tob` | 撒娇男声 | +| 成熟姐姐 | `ICL_zh_female_chengshujiejie_tob` | 成熟女声 | +| 傲娇女友 | `ICL_zh_female_aojiaonvyou_tob` | 傲娇女声 | +| 贴心女友 | `ICL_zh_female_tiexinnvyou_tob` | 温柔女声 | +| 热血少年 | `ICL_zh_male_rexueshaonian_tob` | 热血男声 | +| 冷峻上司 | `ICL_zh_male_lengjunshangsi_tob` | 严肃男声 | + +## IP 仿音(趣味) + +| 音色名称 | voice_type | +|---------|-----------| +| 唐僧 | `zh_male_tangseng_mars_bigtts` | +| 猪八戒 | `zh_male_zhubajie_mars_bigtts` | +| 猴哥 | `zh_male_sunwukong_mars_bigtts` | +| 佩奇猪 | `zh_female_peiqi_mars_bigtts` | +| 熊二 | `zh_male_xionger_mars_bigtts` | +| 鲁班七号 | `zh_male_lubanqihao_mars_bigtts` | + +## 趣味口音 + +| 音色名称 | voice_type | 口音 | +|---------|-----------|------| +| 双节棍小哥 | `zh_male_zhoujielun_emo_v2_mars_bigtts` | 台湾口音 | +| 豫州子轩 | `zh_male_yuzhouzixuan_moon_bigtts` | 河南口音 | +| 呆萌川妹 | `zh_female_daimengchuanmei_moon_bigtts` | 四川口音 | +| 广西远舟 | `zh_male_guangxiyuanzhou_moon_bigtts` | 广西口音 | +| 妹坨洁儿 | `zh_female_meituojieer_moon_bigtts` | 长沙口音 | + +## 通用英文音色 + +### 美式英语 + +| 音色名称 | voice_type | +|---------|-----------| +| Lauren | `en_female_lauren_moon_bigtts` | +| Bruce | `en_male_bruce_moon_bigtts` | +| Michael | `en_male_michael_moon_bigtts` | +| Nara | `en_female_nara_moon_bigtts` | +| Adam | `en_male_adam_mars_bigtts` | +| Amanda | `en_female_amanda_mars_bigtts` | +| Jackson | `en_male_jackson_mars_bigtts` | +| Lucas | `zh_male_M100_conversation_wvae_bigtts` | +| Luna | `en_female_sarah_new_conversation_wvae_bigtts` | + +### 英式英语 + +| 音色名称 | voice_type | +|---------|-----------| +| Emily | `en_female_emily_mars_bigtts` | +| Smith | `en_male_smith_mars_bigtts` | +| Anna | `en_female_anna_mars_bigtts` | +| Dave | `en_male_dave_moon_bigtts` | + +## 情感参数速查 + +### 中文情感 +``` +happy, sad, angry, surprised, fear, hate, excited, coldness, neutral, +depressed, lovey-dovey, shy, comfort, tension, tender, storytelling, +radio, magnetic, advertising, vocal-fry, ASMR, news, entertainment, dialect +``` + +### 英文情感 +``` +neutral, happy, angry, sad, excited, chat, ASMR, warm, affectionate, authoritative +``` -- libgit2 0.21.4