2025-08-15 13:59:37 +08:00

423 lines
12 KiB
TypeScript
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.

/**不同类型 将有不同元数据 */
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;
}