D130: script-parser.js 完成 · 支持场次/角色/台词/特效解析 · 测试通过(10万字75集)
This commit is contained in:
parent
4e2e577bf1
commit
667bf02d8e
263
video-ai-system/engines/script-parser.js
Normal file
263
video-ai-system/engines/script-parser.js
Normal file
@ -0,0 +1,263 @@
|
||||
/**
|
||||
* 光湖视频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,
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user