Commit 17e6455eeb4d9d53fd707a706b8f01d9becf597b

Authored by yibo
1 parent 5261d0d2

docs: add create-drama documentation and scripts

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 +```
... ...