264 lines
7.6 KiB
JavaScript
264 lines
7.6 KiB
JavaScript
|
|
/**
|
|||
|
|
* 光湖视频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,
|
|||
|
|
};
|