forked from 77media/video-flow
更新 API 路径以获取生成的剧本,重构 ScriptService 以支持渲染数据的缓存,新增解析脚本块的功能,优化 StoryDetails 类以简化属性设置,更新测试用例以适应新的逻辑。
This commit is contained in:
parent
ee29ff949b
commit
600b68901f
@ -640,7 +640,7 @@ export const getProjectScript = async (request: {
|
|||||||
/** 生成的剧本文本 */
|
/** 生成的剧本文本 */
|
||||||
generated_script: string;
|
generated_script: string;
|
||||||
}>
|
}>
|
||||||
>("/movie/get_project_script", request);
|
>("/movie/get_generated_script_by_project_id", request);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { useState, useCallback, Dispatch, SetStateAction } from "react";
|
import { useState, useCallback, Dispatch, SetStateAction, useMemo } from "react";
|
||||||
import { ScriptEditUseCase,ScriptEditKey } from "../usecase/ScriptEditUseCase";
|
import { ScriptEditUseCase,ScriptEditKey } from "../usecase/ScriptEditUseCase";
|
||||||
import { getProjectScript, abortVideoTask, pausePlanFlow, resumePlanFlow } from "../../../api/video_flow";
|
import { getProjectScript, abortVideoTask, pausePlanFlow, resumePlanFlow } from "../../../api/video_flow";
|
||||||
|
import { parseScriptBlock } from "../domain/service";
|
||||||
|
import { ScriptBlock } from "@/components/script-renderer/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 剧本服务Hook接口
|
* 剧本服务Hook接口
|
||||||
@ -32,6 +34,8 @@ export interface UseScriptService {
|
|||||||
planId: string;
|
planId: string;
|
||||||
/** AI优化要求 */
|
/** AI优化要求 */
|
||||||
aiOptimizing: string;
|
aiOptimizing: string;
|
||||||
|
/** 渲染数据 */
|
||||||
|
scriptBlocksMemo: ScriptBlock[];
|
||||||
// 操作方法
|
// 操作方法
|
||||||
/** 根据用户想法生成剧本并自动创建项目 */
|
/** 根据用户想法生成剧本并自动创建项目 */
|
||||||
generateScriptFromIdea: (idea: string) => Promise<void>;
|
generateScriptFromIdea: (idea: string) => Promise<void>;
|
||||||
@ -123,7 +127,7 @@ export const useScriptService = (): UseScriptService => {
|
|||||||
// 剧本生成完成后,自动创建项目
|
// 剧本生成完成后,自动创建项目
|
||||||
const projectData = await newScriptEditUseCase.createProject(
|
const projectData = await newScriptEditUseCase.createProject(
|
||||||
idea,
|
idea,
|
||||||
"user123",
|
JSON.parse(localStorage.getItem('currentUser') || '{}').id,
|
||||||
"auto",
|
"auto",
|
||||||
"720p"
|
"720p"
|
||||||
);
|
);
|
||||||
@ -377,6 +381,20 @@ export const useScriptService = (): UseScriptService => {
|
|||||||
}
|
}
|
||||||
}, [scriptEditUseCase, synopsis, focusedField, aiOptimizing, projectId]);
|
}, [scriptEditUseCase, synopsis, focusedField, aiOptimizing, projectId]);
|
||||||
|
|
||||||
|
// 在ScriptService中添加一个方法来获取渲染数据
|
||||||
|
const scriptBlocksMemo = useMemo((): ScriptBlock[] => {
|
||||||
|
return [
|
||||||
|
parseScriptBlock('synopsis', 'Logline', synopsis || ''),
|
||||||
|
parseScriptBlock('categories', 'GENRE', categories.join(', ') || ''),
|
||||||
|
parseScriptBlock('protagonist', 'Core Identity', protagonist || ''),
|
||||||
|
parseScriptBlock('incitingIncident', 'The Inciting Incident', incitingIncident || ''),
|
||||||
|
parseScriptBlock('problem', 'The Problem & New Goal', problem || ''),
|
||||||
|
parseScriptBlock('conflict', 'Conflict & Obstacles', conflict || ''),
|
||||||
|
parseScriptBlock('stakes', 'The Stakes', stakes || ''),
|
||||||
|
parseScriptBlock('characterArc', 'Character Arc Accomplished', characterArc || '')
|
||||||
|
];
|
||||||
|
}, [synopsis, categories, protagonist, incitingIncident, problem, conflict, stakes, characterArc]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// 响应式状态
|
// 响应式状态
|
||||||
loading,
|
loading,
|
||||||
@ -391,7 +409,7 @@ export const useScriptService = (): UseScriptService => {
|
|||||||
projectId,
|
projectId,
|
||||||
planId,
|
planId,
|
||||||
aiOptimizing,
|
aiOptimizing,
|
||||||
|
scriptBlocksMemo,
|
||||||
// 操作方法
|
// 操作方法
|
||||||
generateScriptFromIdea,
|
generateScriptFromIdea,
|
||||||
initializeFromProject,
|
initializeFromProject,
|
||||||
|
|||||||
27
app/service/domain/service.ts
Normal file
27
app/service/domain/service.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { ScriptBlock, ScriptData } from "@/components/script-renderer/types";
|
||||||
|
import { ScriptEditKey } from "../usecase/ScriptEditUseCase";
|
||||||
|
/**
|
||||||
|
* 渲染数据转换器
|
||||||
|
* @param key
|
||||||
|
* @param headerName
|
||||||
|
* @param scriptText
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export function parseScriptBlock(
|
||||||
|
key: ScriptEditKey,
|
||||||
|
headerName: string,
|
||||||
|
scriptText: string
|
||||||
|
): ScriptBlock {
|
||||||
|
return {
|
||||||
|
id: key,
|
||||||
|
title: headerName,
|
||||||
|
type: "core",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: scriptText,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@ -1,5 +1,6 @@
|
|||||||
/**不同类型 将有不同元数据 */
|
/**不同类型 将有不同元数据 */
|
||||||
|
|
||||||
|
|
||||||
export enum ScriptSliceType {
|
export enum ScriptSliceType {
|
||||||
/** 文本 */
|
/** 文本 */
|
||||||
text = "text",
|
text = "text",
|
||||||
@ -143,24 +144,27 @@ export class StoryDetails {
|
|||||||
*/
|
*/
|
||||||
updateScript(text: string) {
|
updateScript(text: string) {
|
||||||
const scriptObject = this.createFromText(text);
|
const scriptObject = this.createFromText(text);
|
||||||
this.synopsis = scriptObject.synopsis;
|
this.setProperties(scriptObject);
|
||||||
this.categories = scriptObject.categories;
|
|
||||||
this.protagonist = scriptObject.protagonist;
|
|
||||||
this.incitingIncident = scriptObject.incitingIncident;
|
|
||||||
this.problem = scriptObject.problem;
|
|
||||||
this.conflict = scriptObject.conflict;
|
|
||||||
this.stakes = scriptObject.stakes;
|
|
||||||
this.characterArc = scriptObject.characterArc;
|
|
||||||
return scriptObject;
|
return scriptObject;
|
||||||
}
|
}
|
||||||
mergeframework():string{
|
setProperties(properties: {
|
||||||
return `
|
synopsis: string;
|
||||||
${this.incitingIncident}\n
|
categories: string[];
|
||||||
${this.problem}
|
protagonist: string;
|
||||||
${this.conflict}
|
incitingIncident: string;
|
||||||
${this.stakes}
|
problem: string;
|
||||||
${this.characterArc}
|
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) {
|
constructor(text: string) {
|
||||||
this.updateScript(text);
|
this.updateScript(text);
|
||||||
@ -175,7 +179,7 @@ export class StoryDetails {
|
|||||||
// --- 智能解析梗概 ---
|
// --- 智能解析梗概 ---
|
||||||
// 梗概通常位于“Core Elements”部分,描述主角和初始事件。
|
// 梗概通常位于“Core Elements”部分,描述主角和初始事件。
|
||||||
// 我们提取“Protagonist”和“The Inciting Incident”的描述,并组合起来。
|
// 我们提取“Protagonist”和“The Inciting Incident”的描述,并组合起来。
|
||||||
const protagonistText = this.extractContentByHeader(text, "Core Identity:");
|
const synopsis = this.extractContentByHeader(text, "Logline");
|
||||||
const incitingIncidentText = this.extractContentByHeader(
|
const incitingIncidentText = this.extractContentByHeader(
|
||||||
text,
|
text,
|
||||||
"The Inciting Incident"
|
"The Inciting Incident"
|
||||||
@ -195,9 +199,6 @@ export class StoryDetails {
|
|||||||
"Character Arc Accomplished"
|
"Character Arc Accomplished"
|
||||||
);
|
);
|
||||||
|
|
||||||
// 1. 梗概 - 从主角描述和激励事件组合生成
|
|
||||||
const synopsis = `${protagonistText} ${incitingIncidentText}`.trim();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
synopsis,
|
synopsis,
|
||||||
categories,
|
categories,
|
||||||
@ -206,7 +207,7 @@ export class StoryDetails {
|
|||||||
problem,
|
problem,
|
||||||
conflict,
|
conflict,
|
||||||
stakes,
|
stakes,
|
||||||
characterArc
|
characterArc,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
@ -215,38 +216,44 @@ export class StoryDetails {
|
|||||||
* @param headerName 要提取内容所属的标题名(不区分大小写)。
|
* @param headerName 要提取内容所属的标题名(不区分大小写)。
|
||||||
* @returns 提取出的纯文本内容。
|
* @returns 提取出的纯文本内容。
|
||||||
*/
|
*/
|
||||||
public extractContentByHeader(fullText: string, headerName: string): string {
|
public extractContentByHeader(
|
||||||
|
fullText: string,
|
||||||
|
headerName: string,
|
||||||
|
debug: boolean = false
|
||||||
|
): string {
|
||||||
try {
|
try {
|
||||||
console.log(`正在查找标题: "${headerName}"`);
|
debug&& console.log(`正在查找标题: "${headerName}"`);
|
||||||
|
|
||||||
// 转义正则表达式中的特殊字符
|
// 转义正则表达式中的特殊字符
|
||||||
const escapeRegex = (text: string): string => {
|
const escapeRegex = (text: string): string => {
|
||||||
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
};
|
};
|
||||||
|
|
||||||
// 清理Markdown格式和多余的空白字符
|
// 清理Markdown格式和多余的空白字符
|
||||||
const cleanMarkdownContent = (content: string): string => {
|
const cleanMarkdownContent = (content: string): string => {
|
||||||
return content
|
return (
|
||||||
|
content
|
||||||
// 移除markdown的粗体标记
|
// 移除markdown的粗体标记
|
||||||
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
||||||
// 移除markdown的斜体标记
|
// 移除markdown的斜体标记
|
||||||
.replace(/\*([^*]+)\*/g, '$1')
|
.replace(/\*([^*]+)\*/g, "$1")
|
||||||
// 移除列表标记(- 或 * 开头的行)
|
// 移除列表标记(- 或 * 开头的行)
|
||||||
.replace(/^\s*[-*]\s+/gm, '')
|
.replace(/^\s*[-*]\s+/gm, "")
|
||||||
// 移除代码块
|
// 移除代码块
|
||||||
.replace(/```[\s\S]*?```/g, '')
|
.replace(/```[\s\S]*?```/g, "")
|
||||||
// 移除行内代码
|
// 移除行内代码
|
||||||
.replace(/`([^`]+)`/g, '$1')
|
.replace(/`([^`]+)`/g, "$1")
|
||||||
// 规范化空白字符:将多个连续空白(包括换行)替换为单个空格
|
// 规范化空白字符:将多个连续空白(包括换行)替换为单个空格
|
||||||
.replace(/\s+/g, ' ')
|
.replace(/\s+/g, " ")
|
||||||
// 移除引号
|
// 移除引号
|
||||||
.replace(/["""'']/g, '')
|
.replace(/["""'']/g, "")
|
||||||
// 最终清理首尾空白
|
// 最终清理首尾空白
|
||||||
.trim();
|
.trim()
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 标准化headerName,移除末尾的冒号和空格
|
// 标准化headerName,移除末尾的冒号和空格
|
||||||
const normalizedHeaderName = headerName.replace(/:?\s*$/, '').trim();
|
const normalizedHeaderName = headerName.replace(/:?\s*$/, "").trim();
|
||||||
const escapedHeaderName = escapeRegex(normalizedHeaderName);
|
const escapedHeaderName = escapeRegex(normalizedHeaderName);
|
||||||
|
|
||||||
let content = "";
|
let content = "";
|
||||||
@ -254,22 +261,40 @@ public extractContentByHeader(fullText: string, headerName: string): string {
|
|||||||
// 定义多种匹配模式,按优先级排序
|
// 定义多种匹配模式,按优先级排序
|
||||||
const patterns = [
|
const patterns = [
|
||||||
// 1. 匹配带编号的主标题:数字. **标题:** 格式
|
// 1. 匹配带编号的主标题:数字. **标题:** 格式
|
||||||
new RegExp(`\\d+\\.\\s*\\*\\*${escapedHeaderName}:?\\*\\*\\s*([\\s\\S]*?)(?=\\d+\\.\\s*\\*\\*[^*]+:?\\*\\*|\\*\\*[A-Z]+:\\*\\*|---|\$)`, "i"),
|
new RegExp(
|
||||||
|
`\\d+\\.\\s*\\*\\*${escapedHeaderName}:?\\*\\*\\s*([\\s\\S]*?)(?=\\d+\\.\\s*\\*\\*[^*]+:?\\*\\*|\\*\\*[A-Z]+:\\*\\*|---|\$)`,
|
||||||
|
"i"
|
||||||
|
),
|
||||||
|
|
||||||
// 2. 匹配子标题:*标题:* 格式(在主标题下的子项)
|
// 2. 匹配子标题:*标题:* 格式(在主标题下的子项)
|
||||||
new RegExp(`\\*\\s*\\*\\*${escapedHeaderName}:?\\*\\*\\s*([\\s\\S]*?)(?=\\*\\s*\\*\\*[^*]+:?\\*\\*|\\d+\\.\\s*\\*\\*[^*]+:?\\*\\*|\\*\\*[A-Z]+:\\*\\*|---|\$)`, "i"),
|
new RegExp(
|
||||||
|
`\\*\\s*\\*\\*${escapedHeaderName}:?\\*\\*\\s*([\\s\\S]*?)(?=\\*\\s*\\*\\*[^*]+:?\\*\\*|\\d+\\.\\s*\\*\\*[^*]+:?\\*\\*|\\*\\*[A-Z]+:\\*\\*|---|\$)`,
|
||||||
|
"i"
|
||||||
|
),
|
||||||
|
|
||||||
// 3. 匹配独立的粗体标题:**标题:** 格式
|
// 3. 匹配独立的粗体标题:**标题:** 格式
|
||||||
new RegExp(`^\\s*\\*\\*${escapedHeaderName}:?\\*\\*\\s*([\\s\\S]*?)(?=^\\s*\\*\\*[^*]+:?\\*\\*|\\d+\\.\\s*\\*\\*[^*]+:?\\*\\*|^\\s*\\*\\*[A-Z]+:\\*\\*|^---|\$)`, "im"),
|
new RegExp(
|
||||||
|
`^\\s*\\*\\*${escapedHeaderName}:?\\*\\*\\s*([\\s\\S]*?)(?=^\\s*\\*\\*[^*]+:?\\*\\*|\\d+\\.\\s*\\*\\*[^*]+:?\\*\\*|^\\s*\\*\\*[A-Z]+:\\*\\*|^---|\$)`,
|
||||||
|
"im"
|
||||||
|
),
|
||||||
|
|
||||||
// 4. 匹配简单的**标题:**格式(可能在段落中)
|
// 4. 匹配简单的**标题:**格式(可能在段落中)
|
||||||
new RegExp(`\\*\\*${escapedHeaderName}:?\\*\\*\\s*([\\s\\S]*?)(?=\\*\\*[^*]+:?\\*\\*|\\d+\\.\\s*\\*\\*[^*]+:?\\*\\*|\\n\\n|---|\$)`, "i"),
|
new RegExp(
|
||||||
|
`\\*\\*${escapedHeaderName}:?\\*\\*\\s*([\\s\\S]*?)(?=\\*\\*[^*]+:?\\*\\*|\\d+\\.\\s*\\*\\*[^*]+:?\\*\\*|\\n\\n|---|\$)`,
|
||||||
|
"i"
|
||||||
|
),
|
||||||
|
|
||||||
// 5. 匹配markdown标题:## 标题 格式
|
// 5. 匹配markdown标题:## 标题 格式
|
||||||
new RegExp(`^#{1,6}\\s*${escapedHeaderName}:?\\s*\\n([\\s\\S]*?)(?=^#{1,6}\\s|^\\*\\*[^*]+:?\\*\\*|^---|\$)`, "im"),
|
new RegExp(
|
||||||
|
`^#{1,6}\\s*${escapedHeaderName}:?\\s*\\n([\\s\\S]*?)(?=^#{1,6}\\s|^\\*\\*[^*]+:?\\*\\*|^---|\$)`,
|
||||||
|
"im"
|
||||||
|
),
|
||||||
|
|
||||||
// 6. 匹配冒号后的内容:标题: 内容(适用于简单的键值对格式)
|
// 6. 匹配冒号后的内容:标题: 内容(适用于简单的键值对格式)
|
||||||
new RegExp(`^\\s*${escapedHeaderName}:?\\s*([\\s\\S]*?)(?=^\\s*[A-Za-z][^:]*:|^\\*\\*[^*]+:?\\*\\*|^---|\$)`, "im"),
|
new RegExp(
|
||||||
|
`^\\s*${escapedHeaderName}:?\\s*([\\s\\S]*?)(?=^\\s*[A-Za-z][^:]*:|^\\*\\*[^*]+:?\\*\\*|^---|\$)`,
|
||||||
|
"im"
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
// 尝试每种模式
|
// 尝试每种模式
|
||||||
@ -278,44 +303,46 @@ public extractContentByHeader(fullText: string, headerName: string): string {
|
|||||||
const match = fullText.match(regex);
|
const match = fullText.match(regex);
|
||||||
if (match && match[1] && match[1].trim()) {
|
if (match && match[1] && match[1].trim()) {
|
||||||
content = match[1].trim();
|
content = match[1].trim();
|
||||||
console.log(`使用模式 ${i + 1} 匹配成功`);
|
debug && console.log(`使用模式 ${i + 1} 匹配成功`);
|
||||||
console.log(`匹配的完整内容: "${match[0].substring(0, 150)}..."`);
|
debug && console.log(`匹配的完整内容: "${match[0].substring(0, 150)}..."`);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!content) {
|
if (!content) {
|
||||||
console.log(`没有找到标题 "${headerName}" 对应的内容`);
|
debug && console.log(`没有找到标题 "${headerName}" 对应的内容`);
|
||||||
// 尝试更宽松的匹配作为最后手段
|
// 尝试更宽松的匹配作为最后手段
|
||||||
const loosePattern = new RegExp(`${escapedHeaderName}[:\\s]*([\\s\\S]{1,500}?)(?=\\n\\n|\\*\\*[^*]+\\*\\*|#{1,6}\\s|---|\$)`, "i");
|
const loosePattern = new RegExp(
|
||||||
|
`${escapedHeaderName}[:\\s]*([\\s\\S]{1,500}?)(?=\\n\\n|\\*\\*[^*]+\\*\\*|#{1,6}\\s|---|\$)`,
|
||||||
|
"i"
|
||||||
|
);
|
||||||
const looseMatch = fullText.match(loosePattern);
|
const looseMatch = fullText.match(loosePattern);
|
||||||
if (looseMatch && looseMatch[1]) {
|
if (looseMatch && looseMatch[1]) {
|
||||||
content = looseMatch[1].trim();
|
content = looseMatch[1].trim();
|
||||||
console.log("使用宽松匹配模式找到内容");
|
debug && console.log("使用宽松匹配模式找到内容");
|
||||||
} else {
|
} else {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`原始匹配内容: "${content.substring(0, 100)}..."`);
|
debug && console.log(`原始匹配内容: "${content.substring(0, 100)}..."`);
|
||||||
|
|
||||||
// 清理内容格式
|
// 清理内容格式
|
||||||
content = cleanMarkdownContent(content);
|
content = cleanMarkdownContent(content);
|
||||||
|
|
||||||
// 如果内容太短,可能是匹配错误,返回空
|
// 如果内容太短,可能是匹配错误,返回空
|
||||||
if (content.length < 10) {
|
if (content.length < 10) {
|
||||||
console.log("匹配到的内容太短,可能匹配错误");
|
debug && console.log("匹配到的内容太短,可能匹配错误");
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`清理后的内容: "${content.substring(0, 100)}..."`);
|
debug && console.log(`清理后的内容: "${content.substring(0, 100)}..."`);
|
||||||
return content;
|
return content;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("内容提取出错:", error);
|
console.error("内容提取出错:", error);
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
toObject() {
|
toObject() {
|
||||||
return {
|
return {
|
||||||
synopsis: this.synopsis,
|
synopsis: this.synopsis,
|
||||||
@ -373,3 +400,4 @@ export class ScriptValueObject {
|
|||||||
return this.scriptText;
|
return this.scriptText;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@ -124,7 +124,7 @@ export class ScriptEditUseCase {
|
|||||||
*/
|
*/
|
||||||
async createProject(
|
async createProject(
|
||||||
prompt: string,
|
prompt: string,
|
||||||
userId: string = "user123",
|
userId: string ,
|
||||||
mode: "auto" | "manual" = "auto",
|
mode: "auto" | "manual" = "auto",
|
||||||
resolution: "720p" | "1080p" | "4k" = "720p"
|
resolution: "720p" | "1080p" | "4k" = "720p"
|
||||||
) {
|
) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user