insert-drama.js 14.9 KB
#!/usr/bin/env node
/**
 * insert-drama.js — 独立脚本:将 classic 类型剧本数据插入数据库
 *
 * 用法:
 *   node insert-drama.js <json文件路径>
 *   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 <json文件> 或 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);
});