forked from 77media/video-flow
423 lines
12 KiB
TypeScript
423 lines
12 KiB
TypeScript
/**不同类型 将有不同元数据 */
|
||
|
||
export enum ScriptSliceType {
|
||
/** 文本 */
|
||
text = "text",
|
||
/**角色 */
|
||
role = "role",
|
||
/** 场景 */
|
||
scene = "scene",
|
||
}
|
||
|
||
/**
|
||
* 剧本片段值对象
|
||
* @description 代表剧本中的一个片段,按值相等判断,不可变
|
||
*/
|
||
export class ScriptSlice {
|
||
/**唯一标识符 - 仅作为局部唯一性标识,不作为全局Entity id */
|
||
readonly id: string;
|
||
/** 类型 */
|
||
readonly type: ScriptSliceType;
|
||
/** 剧本内容 */
|
||
readonly text: string;
|
||
/** 元数据 */
|
||
metaData: any;
|
||
|
||
constructor(
|
||
id: string,
|
||
type: ScriptSliceType,
|
||
text: string,
|
||
metaData: any = {}
|
||
) {
|
||
this.id = id;
|
||
this.type = type;
|
||
this.text = text;
|
||
this.metaData = metaData;
|
||
}
|
||
|
||
/**
|
||
* 值对象相等性比较
|
||
* @param other 另一个ScriptSlice实例
|
||
* @returns 是否相等
|
||
*/
|
||
equals(other: ScriptSlice): boolean {
|
||
return (
|
||
this.type === other.type &&
|
||
this.text === other.text &&
|
||
JSON.stringify(this.metaData) === JSON.stringify(other.metaData)
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 对话内容项值对象
|
||
* @description 代表对话中的一个内容项,按值相等判断,不可变
|
||
*/
|
||
export interface ContentItem {
|
||
/** 角色名称 */
|
||
roleName: string;
|
||
/** 对话内容 */
|
||
content: string;
|
||
}
|
||
|
||
/**
|
||
* 镜头值对象
|
||
* @description 代表镜头信息,按值相等判断,不可变
|
||
*/
|
||
export class LensType {
|
||
/**镜头名称 */
|
||
readonly name: string;
|
||
/**镜头描述 */
|
||
readonly script: string;
|
||
/**对话内容 */
|
||
readonly content: ContentItem[];
|
||
|
||
constructor(name: string, script: string, content: ContentItem[]) {
|
||
this.name = name;
|
||
this.script = script;
|
||
this.content = content;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 标签实体接口
|
||
*/
|
||
export interface TagValueObject {
|
||
/** 唯一标识 */
|
||
readonly id: string;
|
||
/** 标签名称 */
|
||
name: string;
|
||
/** 内容标签类型 */
|
||
content: number | string;
|
||
|
||
/** 颜色 */
|
||
color?: string;
|
||
}
|
||
|
||
|
||
/**
|
||
* 这是一个符合DDD概念的值对象,代表故事的核心细节。
|
||
* 它封装了故事的四个关键元素:梗概、分类、主角和框架内容。
|
||
* - 值对象是不可变的,一旦创建,其内部属性不会改变。
|
||
* - 构造函数是私有的,强制使用工厂方法创建实例,以确保数据处理的封装性。
|
||
*/
|
||
export class StoryDetails {
|
||
/** 故事梗概 */
|
||
public synopsis: string = "";
|
||
|
||
/** 故事分类标签 */
|
||
public categories: string[] = [];
|
||
|
||
/** 主角名称 */
|
||
public protagonist: string = "";
|
||
|
||
/** 激励事件 */
|
||
public incitingIncident: string = "";
|
||
|
||
/** 问题与新目标 */
|
||
public problem: string = "";
|
||
|
||
/** 冲突与障碍 */
|
||
public conflict: string = "";
|
||
|
||
/** 赌注 */
|
||
public stakes: string = "";
|
||
|
||
/** 人物弧线完成 */
|
||
public characterArc: string = "";
|
||
|
||
/**
|
||
* 更新剧本
|
||
* @param text 剧本文本
|
||
*/
|
||
updateScript(text: string) {
|
||
console.log('text.length', text.length)
|
||
const scriptObject = this.createFromText(text);
|
||
console.log('scriptObject', scriptObject)
|
||
this.setProperties(scriptObject);
|
||
return scriptObject;
|
||
}
|
||
setProperties(properties: {
|
||
synopsis: string;
|
||
categories: string[];
|
||
protagonist: string;
|
||
incitingIncident: string;
|
||
problem: string;
|
||
conflict: string;
|
||
stakes: string;
|
||
characterArc: string;
|
||
}) {
|
||
this.synopsis = properties.synopsis;
|
||
this.categories = properties.categories;
|
||
this.protagonist = properties.protagonist;
|
||
this.incitingIncident = properties.incitingIncident;
|
||
this.problem = properties.problem;
|
||
this.conflict = properties.conflict;
|
||
this.stakes = properties.stakes;
|
||
this.characterArc = properties.characterArc;
|
||
}
|
||
constructor(text: string) {
|
||
this.updateScript(text);
|
||
}
|
||
|
||
/**
|
||
* 工厂方法:从原始英文文本中提取并创建 StoryDetails 值对象。
|
||
* 该方法负责所有的解析逻辑,包括智能提取梗概和去除格式标签。
|
||
* @param text 包含故事所有细节的原始Markdown或富文本。
|
||
*/
|
||
public createFromText(text: string) {
|
||
try {
|
||
// --- 智能解析梗概 ---
|
||
// 梗概通常位于“Core Elements”部分,描述主角和初始事件。
|
||
// 我们提取“Protagonist”和“The Inciting Incident”的描述,并组合起来。
|
||
const synopsis = this.extractContentByHeader(text, "Logline");
|
||
const incitingIncidentText = this.extractContentByHeader(
|
||
text,
|
||
"The Inciting Incident",
|
||
|
||
);
|
||
// --- 其他字段的提取保持不变,使用标题进行查找 ---
|
||
const categories = this.extractContentByHeader(text, "GENRE")
|
||
.split(",")
|
||
.map((s) => s.trim())
|
||
.filter((s) => s.length > 0);
|
||
const protagonist = this.extractContentByHeader(text, "Core Identity:");
|
||
const problem = this.extractContentByHeader(text, "The Problem & New Goal");
|
||
const conflict = this.extractContentByHeader(text, "Conflict & Obstacles");
|
||
const stakes = this.extractContentByHeader(text, "The Stakes");
|
||
const characterArc = this.extractContentByHeader(
|
||
text,
|
||
"Character Arc Accomplished"
|
||
);
|
||
|
||
return {
|
||
synopsis,
|
||
categories,
|
||
protagonist,
|
||
incitingIncident: incitingIncidentText,
|
||
problem,
|
||
conflict,
|
||
stakes,
|
||
characterArc,
|
||
};
|
||
} catch (error) {
|
||
return {
|
||
synopsis: "",
|
||
categories: [],
|
||
protagonist: "",
|
||
incitingIncident: "",
|
||
problem: "",
|
||
conflict: "",
|
||
stakes: "",
|
||
characterArc: "",
|
||
};
|
||
}
|
||
}
|
||
/**
|
||
* 辅助方法:根据标题提取其下的内容,并去除所有Markdown标签。
|
||
* @param fullText 完整的源文本。
|
||
* @param headerName 要提取内容所属的标题名(不区分大小写)。
|
||
* @returns 提取出的纯文本内容。
|
||
*/
|
||
public extractContentByHeader(
|
||
fullText: string,
|
||
headerName: string,
|
||
debug: boolean = false
|
||
): string {
|
||
try {
|
||
debug && console.log(`正在查找标题: "${headerName}"`);
|
||
|
||
// 转义正则表达式中的特殊字符
|
||
const escapeRegex = (text: string): string => {
|
||
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||
};
|
||
|
||
// 清理Markdown格式和多余的空白字符
|
||
const cleanMarkdownContent = (content: string): string => {
|
||
return (
|
||
content
|
||
// 移除markdown的粗体标记
|
||
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
||
// 移除markdown的斜体标记
|
||
.replace(/\*([^*]+)\*/g, "$1")
|
||
// 移除列表标记(- 或 * 开头的行)
|
||
.replace(/^\s*[-*]\s+/gm, "")
|
||
// 移除代码块
|
||
.replace(/```[\s\S]*?```/g, "")
|
||
// 移除行内代码
|
||
.replace(/`([^`]+)`/g, "$1")
|
||
// 规范化空白字符:将多个连续空白(包括换行)替换为单个空格
|
||
.replace(/\s+/g, " ")
|
||
// 移除引号
|
||
.replace(/["""'']/g, "")
|
||
// 最终清理首尾空白
|
||
.trim()
|
||
);
|
||
};
|
||
|
||
// 标准化headerName,移除末尾的冒号和空格
|
||
const normalizedHeaderName = headerName.replace(/:?\s*$/, "").trim();
|
||
const escapedHeaderName = escapeRegex(normalizedHeaderName);
|
||
|
||
let content = "";
|
||
|
||
// 定义多种匹配模式,按优先级排序
|
||
const patterns = [
|
||
// 1. 匹配带编号的主标题:数字. **标题:** 格式
|
||
new RegExp(
|
||
`\\d+\\.\\s*\\*\\*${escapedHeaderName}:?\\*\\*\\s*([\\s\\S]*?)(?=\\d+\\.\\s*\\*\\*[^*]+:?\\*\\*|\\*\\*[A-Z]+:\\*\\*|---|\$)`,
|
||
"i"
|
||
),
|
||
|
||
// 2. 匹配子标题:*标题:* 格式(在主标题下的子项)
|
||
new RegExp(
|
||
`\\*\\s*\\*\\*${escapedHeaderName}:?\\*\\*\\s*([\\s\\S]*?)(?=\\*\\s*\\*\\*[^*]+:?\\*\\*|\\d+\\.\\s*\\*\\*[^*]+:?\\*\\*|\\*\\*[A-Z]+:\\*\\*|---|\$)`,
|
||
"i"
|
||
),
|
||
|
||
// 3. 匹配独立的粗体标题:**标题:** 格式
|
||
new RegExp(
|
||
`^\\s*\\*\\*${escapedHeaderName}:?\\*\\*\\s*([\\s\\S]*?)(?=^\\s*\\*\\*[^*]+:?\\*\\*|\\d+\\.\\s*\\*\\*[^*]+:?\\*\\*|^\\s*\\*\\*[A-Z]+:\\*\\*|^---|\$)`,
|
||
"im"
|
||
),
|
||
|
||
// 4. 匹配简单的粗体键值对格式:**标题:** 值(适用于简单的键值对,如 **GENRE:** Drama)
|
||
new RegExp(`\\*\\*${escapedHeaderName}:?\\*\\*\\s*([^\\n]+)`, "i"),
|
||
|
||
// 5. 匹配简单的**标题:**格式(可能在段落中)
|
||
new RegExp(
|
||
`\\*\\*${escapedHeaderName}:?\\*\\*\\s*([\\s\\S]*?)(?=\\*\\*[^*]+:?\\*\\*|\\d+\\.\\s*\\*\\*[^*]+:?\\*\\*|\\n\\n|---|\$)`,
|
||
"i"
|
||
),
|
||
|
||
// 6. 匹配markdown标题:## 标题 格式
|
||
new RegExp(
|
||
`^#{1,6}\\s*${escapedHeaderName}:?\\s*\\n([\\s\\S]*?)(?=^#{1,6}\\s|^\\*\\*[^*]+:?\\*\\*|^---|\$)`,
|
||
"im"
|
||
),
|
||
|
||
// 7. 匹配冒号后的内容:标题: 内容(适用于简单的键值对格式)
|
||
new RegExp(
|
||
`^\\s*${escapedHeaderName}:?\\s*([\\s\\S]*?)(?=^\\s*[A-Za-z][^:]*:|^\\*\\*[^*]+:?\\*\\*|^---|\$)`,
|
||
"im"
|
||
),
|
||
];
|
||
|
||
// 尝试每种模式
|
||
for (let i = 0; i < patterns.length; i++) {
|
||
const regex = patterns[i];
|
||
const match = fullText.match(regex);
|
||
if (match && match[1] && match[1].trim()) {
|
||
content = match[1].trim();
|
||
debug && console.log(`使用模式 ${i + 1} 匹配成功`);
|
||
debug &&
|
||
console.log(`匹配的完整内容: "${match[0].substring(0, 150)}..."`);
|
||
break;
|
||
}
|
||
}
|
||
// console.log(headerName, content)
|
||
if (!content) {
|
||
debug && console.log(`没有找到标题 "${headerName}" 对应的内容`);
|
||
// 尝试更宽松的匹配作为最后手段
|
||
const loosePattern = new RegExp(
|
||
`${escapedHeaderName}[:\\s]*([\\s\\S]{1,500}?)(?=\\n\\n|\\*\\*[^*]+\\*\\*|#{1,6}\\s|---|\$)`,
|
||
"i"
|
||
);
|
||
const looseMatch = fullText.match(loosePattern);
|
||
if (looseMatch && looseMatch[1]) {
|
||
content = looseMatch[1].trim();
|
||
debug && console.log("使用宽松匹配模式找到内容");
|
||
} else {
|
||
return "";
|
||
}
|
||
}
|
||
|
||
debug && console.log(`原始匹配内容: "${content.substring(0, 100)}..."`);
|
||
|
||
// 清理内容格式
|
||
content = cleanMarkdownContent(content);
|
||
|
||
// 如果内容太短,可能是匹配错误,返回空
|
||
if (content.length < 20&&headerName!=='GENRE') {
|
||
debug && console.log("匹配到的内容太短,可能匹配错误");
|
||
return "";
|
||
}
|
||
|
||
debug && console.log(`清理后的内容: "${content.substring(0, 100)}..."`);
|
||
return content;
|
||
} catch (error) {
|
||
console.log("内容提取出错:", error);
|
||
return "";
|
||
}
|
||
}
|
||
toObject() {
|
||
return {
|
||
synopsis: this.synopsis,
|
||
categories: this.categories,
|
||
protagonist: this.protagonist,
|
||
incitingIncident: this.incitingIncident,
|
||
problem: this.problem,
|
||
conflict: this.conflict,
|
||
stakes: this.stakes,
|
||
characterArc: this.characterArc,
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 剧本聚合根
|
||
* @description 作为聚合根,封装ScriptSlice的管理与行为
|
||
*/
|
||
export class ScriptValueObject {
|
||
/** 剧本片段数组 - 值对象数组 */
|
||
private readonly _scriptSlices: ScriptSlice[] = [];
|
||
|
||
/**
|
||
* 获取剧本片段数组的只读副本
|
||
*/
|
||
get scriptSlices(): readonly ScriptSlice[] {
|
||
return [...this._scriptSlices];
|
||
}
|
||
scriptText: string = "";
|
||
storyDetails: StoryDetails;
|
||
/**
|
||
* @description: 构造函数,初始化剧本
|
||
* @param scriptText 剧本文本字符串
|
||
*/
|
||
constructor(scriptText?: string) {
|
||
this.storyDetails = new StoryDetails("");
|
||
if (scriptText) {
|
||
this.parseFromString(scriptText);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @description: 从字符串解析剧本片段
|
||
* @param scriptText 剧本文本字符串
|
||
*/
|
||
parseFromString(scriptText: string) {
|
||
this.scriptText += scriptText;
|
||
return this.storyDetails.updateScript(this.scriptText);
|
||
}
|
||
/**
|
||
* @description: 将剧本片段转换为字符串
|
||
* @returns string
|
||
*/
|
||
toString(): string {
|
||
return this.scriptText;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 角色简单数据接口
|
||
* @description 包含角色姓名、图片和描述
|
||
*/
|
||
export interface SimpleCharacter {
|
||
/** 姓名 */
|
||
name: string;
|
||
/** 图片URL */
|
||
imageUrl: string;
|
||
/** 角色描述 */
|
||
description: string;
|
||
}
|