冰朔 667bf02d8e
Some checks failed
自动更新代码和重启 / update-and-restart (push) Has been cancelled
CI检查 + 自动部署 / check (push) Has been cancelled
CI检查 + 自动部署 / deploy (push) Has been cancelled
D130: script-parser.js 完成 · 支持场次/角色/台词/特效解析 · 测试通过(10万字75集)
2026-06-11 19:49:31 +08:00

264 lines
7.6 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 光湖视频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,
};