#!/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); });