From 667bf02d8eabb5f6c3bf0ca3acdec0f3d3580300 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=B0=E6=9C=94?= <565183519@qq.com> Date: Thu, 11 Jun 2026 19:49:31 +0800 Subject: [PATCH] =?UTF-8?q?D130:=20script-parser.js=20=E5=AE=8C=E6=88=90?= =?UTF-8?q?=20=C2=B7=20=E6=94=AF=E6=8C=81=E5=9C=BA=E6=AC=A1/=E8=A7=92?= =?UTF-8?q?=E8=89=B2/=E5=8F=B0=E8=AF=8D/=E7=89=B9=E6=95=88=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=20=C2=B7=20=E6=B5=8B=E8=AF=95=E9=80=9A=E8=BF=87(10?= =?UTF-8?q?=E4=B8=87=E5=AD=9775=E9=9B=86)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- video-ai-system/engines/script-parser.js | 263 +++++++++++++++++++++++ 1 file changed, 263 insertions(+) create mode 100644 video-ai-system/engines/script-parser.js diff --git a/video-ai-system/engines/script-parser.js b/video-ai-system/engines/script-parser.js new file mode 100644 index 0000000..886773f --- /dev/null +++ b/video-ai-system/engines/script-parser.js @@ -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, +};