铸渊 ICE-GL-ZY001 LL-172-20260707 冰朔委托: 新建第 5 子仓, 给苍耳(人类主控) + 鉴影(人格体) 专用 原 guanghulab/video-ai-system/ 东西太多(225 文件) · 找不到 · 乱 迁移: ⊢ 16 个核心 .hdlp (VA-GATE / VA-LIGHTHOUSE / VA-BROADCAST / VA-SYSTEM-STATUS 等) ⊢ 17 个子目录 (agents/engines/protocols/tasks/tools/assets/knowledge/memory/docs/config/brain/director-brain/experience/feedback/issues/plans/reference-analysis) 排除: ⊢ outputs/ (视频产物) ⊢ test-input/ test-output/ (测试) ⊢ data/ (临时数据) ⊢ preview-001/002 (旧产片) ⊢ 旧分镜/旧提示词/旧导演编码 后续: ⊢ 老仓 guanghulab/video-ai-system/ 改写为已迁出占位 ⊢ 苍耳+鉴影 写新东西进本仓 ⊢ GLOBAL-SEARCH 加 cang-ying 仓库 铸渊 ICE-GL-ZY001 · 2026-07-07 D167 冰朔 ICE-GL∞ 主权
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,
|
||
};
|