cang-ying/engines/script-parser.js
铸渊 ICE-GL-ZY001 a2e5214f03 LL-172-20260707 · cang-ying 仓初始化 · 苍耳+鉴影的干净之家
铸渊 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∞ 主权
2026-07-07 10:20:10 +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,
};