cang-ying/engines/script-parser.js

264 lines
7.6 KiB
JavaScript
Raw Normal View History

/**
* 光湖视频AI系统 · 剧本解析器
* D130 · 铸渊 ICE-GL-ZY001
*
* 支持格式: 动态漫/短剧标准剧本
* 输入: 腾讯文档读出的纯文本
* 输出: 结构化 JSON剧集场次角色台词场景描述
*/
/**
* 解析剧本全文
* @param {string} text - 剧本纯文本
* @param {object} [opts]
* @param {number} [opts.maxEpisodes] - 最多解析多少集默认全部
* @returns {object} { title, episodes: [...], stats }
*/
function parseScript(text, opts = {}) {
const { maxEpisodes = Infinity } = opts;
// 提取标题(第一行非空)
const lines = text.split('\n').map(l => l.trim()).filter(Boolean);
let title = '';
let startIdx = 0;
for (let i = 0; i < lines.length; i++) {
if (!lines[i].startsWith('第') || !lines[i].includes('集')) {
title += lines[i].replace(/[:]/g, '').trim();
} else {
startIdx = i;
break;
}
}
// 按「第N集」分割
const episodes = [];
let currentEpisode = null;
let currentScene = null;
for (let i = startIdx; i < lines.length; i++) {
const line = lines[i];
// 检测新剧集
const epMatch = line.match(/^第(\d+)集[:]?/);
if (epMatch) {
if (episodes.length >= maxEpisodes) break;
if (currentScene) {
if (currentEpisode) currentEpisode.scenes.push(currentScene);
currentScene = null;
}
currentEpisode = {
episodeNumber: parseInt(epMatch[1]),
title: line.replace(/^第\d+集[:]\s*/, ''),
scenes: [],
};
episodes.push(currentEpisode);
continue;
}
if (!currentEpisode) continue;
// 检测新场次: 「N-M 时间 场景 地点」
const sceneMatch = line.match(/^(\d+-\d+)\s+(日|夜|晨|暮|傍晚|黎明|黄昏|清晨|深夜)\s+(内|外)\s+(.+)$/);
if (sceneMatch) {
if (currentScene) {
currentEpisode.scenes.push(currentScene);
}
currentScene = {
id: sceneMatch[1],
time: sceneMatch[2],
location: sceneMatch[3],
name: sceneMatch[4].trim(),
characters: [],
descriptions: [],
dialogues: [],
effects: [],
};
continue;
}
if (!currentScene) continue;
// 人物行: 「人物A描述B描述...」
const charMatch = line.match(/^人物[:]\s*(.+)$/);
if (charMatch) {
const charText = charMatch[1];
// 用正则匹配每个「名字(描述)」或「名字」单元(描述中可有逗号)
const charRe = /([^,、]+?)(?:[(]([^)]+)[)])?(?=[,、]|$)/g;
let cm;
while ((cm = charRe.exec(charText)) !== null) {
const name = cm[1].trim();
if (name) {
currentScene.characters.push({
name,
description: (cm[2] || '').trim(),
});
}
}
continue;
}
// 场景描述: 「△......」
if (line.startsWith('△')) {
const desc = line.replace(/^△\s*/, '');
// 检测特效
if (desc.startsWith('特效:') || desc.startsWith('特效:')) {
currentScene.effects.push({
type: 'effect',
description: desc.replace(/^特效[:]\s*/, ''),
raw: line,
});
} else if (desc.startsWith('特写:') || desc.startsWith('特写:')) {
currentScene.effects.push({
type: 'closeup',
description: desc.replace(/^特写[:]\s*/, ''),
raw: line,
});
} else if (desc.startsWith('切画面') || desc.startsWith('闪切')) {
currentScene.effects.push({
type: 'transition',
description: desc,
raw: line,
});
} else {
currentScene.descriptions.push(desc);
}
continue;
}
// 对话/独白: 「名字(情绪):台词」 或 「名字情绪OS台词」
const dialogueMatch = line.match(/^(.+?)[(](.+?)[)](OS)?[:]\s*(.+)$/);
if (dialogueMatch) {
currentScene.dialogues.push({
character: dialogueMatch[1].trim(),
emotion: dialogueMatch[2].trim(),
type: dialogueMatch[3] ? 'inner' : 'dialogue',
line: dialogueMatch[4].trim(),
});
continue;
}
// 系统界面: 「系统:」
const systemMatch = line.match(/^系统[:]\s*(.+)$/);
if (systemMatch) {
currentScene.effects.push({
type: 'system_ui',
description: systemMatch[1],
raw: line,
});
continue;
}
// 其他行(可能是续行或多行描述)→ 追加到当前最后一条description
if (line && currentScene) {
const lastDesc = currentScene.descriptions;
const lastDialogues = currentScene.dialogues;
if (lastDialogues.length > 0) {
lastDialogues[lastDialogues.length - 1].line += ' ' + line;
} else if (lastDesc.length > 0) {
lastDesc[lastDesc.length - 1] += ' ' + line;
}
}
}
// 处理最后一幕
if (currentScene && currentEpisode) {
currentEpisode.scenes.push(currentScene);
}
return {
title: title || '未命名剧本',
episodes,
stats: {
totalEpisodes: episodes.length,
totalScenes: episodes.reduce((s, e) => s + e.scenes.length, 0),
totalDialogues: episodes.reduce((s, e) =>
s + e.scenes.reduce((ss, sc) => ss + sc.dialogues.length, 0), 0),
totalCharacters: countUniqueCharacters(episodes),
},
};
}
/**
* 统计不重复角色
*/
function countUniqueCharacters(episodes) {
const chars = new Set();
for (const ep of episodes) {
for (const sc of ep.scenes) {
for (const c of sc.characters) chars.add(c.name);
for (const d of sc.dialogues) chars.add(d.character);
}
}
return chars.size;
}
/**
* 为视频AI生成Markdown格式的分镜摘要铸渊阅读用
* @param {object} scene - 单场场景对象
*/
function sceneToMarkdown(scene) {
const lines = [];
lines.push(`## 第${scene.id}场 · ${scene.name}`);
lines.push(`**时间**: ${scene.time} | **场景**: ${scene.location}`);
lines.push('');
if (scene.characters.length) {
lines.push('**出场人物**:');
for (const c of scene.characters) {
lines.push(`- ${c.name}${c.description ? `${c.description}` : ''}`);
}
lines.push('');
}
if (scene.descriptions.length || scene.effects.length) {
lines.push('**画面**:');
for (const d of scene.descriptions) lines.push(`- ${d}`);
for (const e of scene.effects) {
if (e.type === 'closeup') lines.push(`- 🎥 特写: ${e.description}`);
else if (e.type === 'transition') lines.push(`- ✂️ ${e.description}`);
else if (e.type === 'system_ui') lines.push(`- 📟 系统UI: ${e.description}`);
else lines.push(`- ✨ 特效: ${e.description}`);
}
lines.push('');
}
if (scene.dialogues.length) {
lines.push('**台词**:');
for (const d of scene.dialogues) {
const prefix = d.type === 'inner' ? '💭' : '🗣️';
lines.push(`- ${prefix} **${d.character}**${d.emotion}: ${d.line}`);
}
lines.push('');
}
return lines.join('\n');
}
/**
* 提取所有角色的完整信息跨集合并
*/
function extractAllCharacters(episodes) {
const map = new Map();
for (const ep of episodes) {
for (const sc of ep.scenes) {
for (const c of sc.characters) {
if (!map.has(c.name) || (c.description && !map.get(c.name).description)) {
map.set(c.name, {
name: c.name,
description: c.description || map.get(c.name)?.description || '',
firstAppearance: ep.episodeNumber,
});
}
}
}
}
return Array.from(map.values());
}
module.exports = {
parseScript,
sceneToMarkdown,
extractAllCharacters,
};