diff --git a/api/video_flow.ts b/api/video_flow.ts index 8e8fe41..1069512 100644 --- a/api/video_flow.ts +++ b/api/video_flow.ts @@ -509,8 +509,6 @@ export const regenerateShot = async (request: { shotId?: string; /** 镜头描述 */ shotPrompt?: LensType[]; - /** 对话内容 */ - dialogueContent?: ContentItem[]; /** 角色ID替换参数,格式为{oldId:string,newId:string}[] */ roleReplaceParams?: { oldId: string; newId: string }[]; /** 场景ID替换参数,格式为{oldId:string,newId:string}[] */ @@ -726,22 +724,6 @@ export const enhanceScriptStream = ( request: { /** 原始剧本文本 */ original_script: string; - /** 故事梗概 */ - synopsis?: string; - /** 故事分类 */ - categories?: string[]; - /** 主角名称 */ - protagonist?: string; - /** 激励事件 */ - incitingIncident?: string; - /** 问题与新目标 */ - problem?: string; - /** 冲突与障碍 */ - conflict?: string; - /** 赌注 */ - stakes?: string; - /** 人物弧线完成 */ - characterArc?: string; /** AI优化要求 */ aiOptimizing: string; }, @@ -767,3 +749,19 @@ export const enhanceScriptStream = ( }) }) }; + +/** + * AI优化镜头内容接口 + * @param request - AI优化请求参数 + * @returns Promise> 优化后的镜头数据 + */ +export const optimizeShotContent = async (request: { + /** 视频片段ID */ + shotId: string; + /** 用户优化需求 */ + userRequirement: string; + /** 镜头数据数组 */ + lensData: LensType[]; +}): Promise> => { + return post>("/api/v1/shot/optimize_content", request); +}; diff --git a/app/service/Interaction/ScriptService.ts b/app/service/Interaction/ScriptService.ts index 0a89abc..e6fecf3 100644 --- a/app/service/Interaction/ScriptService.ts +++ b/app/service/Interaction/ScriptService.ts @@ -51,7 +51,7 @@ export interface UseScriptService { /** 根据用户想法生成剧本并自动创建项目 */ generateScriptFromIdea: (idea: string) => Promise; /** 根据项目ID初始化已有剧本 */ - initializeFromProject: (projectId: string, script?: string) => Promise; + initializeFromProject: (projectId: string, script: string) => Promise; /** 修改剧本 */ updateScript: (scriptText: string) => Promise; /** 应用剧本到视频生成流程 */ @@ -132,7 +132,6 @@ export const useScriptService = (): UseScriptService => { const [scriptEditUseCase, setScriptEditUseCase] = useState( new ScriptEditUseCase("") ); - /** * 根据用户想法生成剧本并自动创建项目 * @param idea 用户想法 @@ -167,7 +166,7 @@ export const useScriptService = (): UseScriptService => { setLoading(false); } }, - [] + [projectId, scriptEditUseCase] ); const createMovieProjectV1 = useCallback( @@ -231,6 +230,7 @@ export const useScriptService = (): UseScriptService => { // 获取解析后的故事详情 const storyDetails = newScriptEditUseCase.getStoryDetails(); + setSynopsis(storyDetails.synopsis || ""); setCategories(storyDetails.categories || []); setProtagonist(storyDetails.protagonist || ""); @@ -246,7 +246,7 @@ export const useScriptService = (): UseScriptService => { setLoading(false); } }, - [] + [projectId, scriptEditUseCase] ); /** @@ -437,8 +437,6 @@ export const useScriptService = (): UseScriptService => { // 调用增强剧本方法 await scriptEditUseCase.enhanceScript( - synopsis, - focusedField as ScriptEditKey, aiOptimizing, (content: any) => { // 获取解析后的故事详情 @@ -464,7 +462,7 @@ export const useScriptService = (): UseScriptService => { } finally { setLoading(false); } - }, [scriptEditUseCase, synopsis, focusedField, aiOptimizing, projectId]); + }, [scriptEditUseCase, aiOptimizing, projectId]); // 在ScriptService中添加一个方法来获取渲染数据 const scriptBlocksMemo = useMemo((): ScriptBlock[] => { @@ -500,6 +498,7 @@ export const useScriptService = (): UseScriptService => { conflict, stakes, characterArc, + ]); return { diff --git a/app/service/Interaction/ShotService.ts b/app/service/Interaction/ShotService.ts index 99e6f5c..f824a69 100644 --- a/app/service/Interaction/ShotService.ts +++ b/app/service/Interaction/ShotService.ts @@ -1,386 +1,325 @@ -import { useState, useCallback, useMemo } from "react"; -import { - VideoSegmentEntity, - RoleEntity, - SceneEntity, -} from "../domain/Entities"; -import { ContentItem, ScriptSlice, ScriptValueObject, LensType } from "../domain/valueObject"; -import { VideoSegmentItem } from "../domain/Item"; +import { useState, useCallback } from "react"; import { VideoSegmentEditUseCase } from "../usecase/ShotEditUsecase"; -import { - getShotList, - // getShotDetail, - updateShotContent, - getUserRoleLibrary, - replaceShotRole, - getShotVideoScript, -} from "@/api/video_flow"; +import { VideoSegmentEntity } from "../domain/Entities"; +import { ContentItem, LensType } from "../domain/valueObject"; /** * 视频片段服务Hook接口 * 定义视频片段服务Hook的所有状态和操作方法 */ -export interface UseVideoSegmentService { +export interface UseShotService { // 响应式状态 - /** 视频片段列表 */ - videoSegmentList: VideoSegmentEntity[]; - /** 当前选中的视频片段 */ - selectedVideoSegment: VideoSegmentItem | null; - /** 当前视频片段的草图数据URL */ - videoSegmentSketchData: string | null; - /** 当前视频片段的视频数据URL */ - videoSegmentVideoData: string[] | null; - - /** 用户角色库 */ - userRoleLibrary: RoleEntity[]; - /** 当前视频片段的视频剧本片段 */ - videoSegmentVideoScript: ScriptSlice[]; /** 加载状态 */ loading: boolean; + /** 视频片段列表 */ + videoSegments: VideoSegmentEntity[]; + /** 当前选中的视频片段 */ + selectedSegment: VideoSegmentEntity | null; /** 错误信息 */ error: string | null; // 操作方法 /** 获取视频片段列表 */ - fetchVideoSegmentList: (projectId: string) => Promise; - /** 选择视频片段并获取详情 */ - selectVideoSegment: (videoSegmentId: string) => Promise; - /** 修改视频片段对话内容 */ - updateVideoSegmentContent: ( - newContent: Array<{ roleId: string; content: string }> - ) => Promise; - /** 获取用户角色库 */ - fetchUserRoleLibrary: () => Promise; - /** 替换视频片段角色 */ - replaceVideoSegmentRole: (oldRoleId: string, newRoleId: string) => Promise; - /** 获取视频片段视频剧本内容 */ - fetchVideoSegmentVideoScript: () => Promise; - /** 获取视频片段关联的角色信息 */ - getVideoSegmentRoles: () => Promise; - /** 获取视频片段关联的场景信息 */ - getVideoSegmentScenes: () => Promise; - + getVideoSegmentList: (projectId: string) => Promise; /** 重新生成视频片段 */ regenerateVideoSegment: ( shotPrompt: LensType[], - dialogueContent: ContentItem[], - roleReplaceParams: { oldId: string; newId: string }[], - sceneReplaceParams: { oldId: string; newId: string }[] - ) => Promise; + shotId?: string, + roleReplaceParams?: { oldId: string; newId: string }[], + sceneReplaceParams?: { oldId: string; newId: string }[] + ) => Promise; + /** AI优化视频内容 */ + optimizeVideoContent: ( + shotId: string, + userRequirement: string, + lensData: LensType[] + ) => Promise; + /** 更新视频内容 */ + updateVideoContent: ( + shotId: string, + content: Array<{ roleId: string; content: string }> + ) => Promise; + /** 中断当前操作 */ + abortOperation: () => void; + /** 设置选中的视频片段 */ + setSelectedSegment: (segment: VideoSegmentEntity | null) => void; + /** 清除错误信息 */ + clearError: () => void; } /** * 视频片段服务Hook * 提供视频片段相关的所有状态管理和操作方法 - * 包括视频片段列表管理、视频片段选择、数据获取、内容修改等功能 + * 包括获取视频列表、重新生成视频、AI优化等功能 */ -export const useVideoSegmentService = (): UseVideoSegmentService => { +export const useShotService = (): UseShotService => { // 响应式状态 - const [videoSegmentList, setVideoSegmentList] = useState([]); - const [selectedVideoSegment, setSelectedVideoSegment] = useState(null); - const [videoSegmentSketchData, setVideoSegmentSketchData] = useState(null); - const [videoSegmentVideoData, setVideoSegmentVideoData] = useState(null); - const [userRoleLibrary, setUserRoleLibrary] = useState([]); - const [videoSegmentVideoScript, setVideoSegmentVideoScript] = useState([]); const [loading, setLoading] = useState(false); + const [videoSegments, setVideoSegments] = useState([]); + const [selectedSegment, setSelectedSegment] = useState(null); const [error, setError] = useState(null); - const [projectId, setProjectId] = useState(""); + // UseCase实例 - const [videoSegmentEditUseCase, setVideoSegmentEditUseCase] = - useState(null); + const [shotEditUseCase] = useState( + new VideoSegmentEditUseCase() + ); /** * 获取视频片段列表 - * @description 根据项目ID获取所有视频片段列表 * @param projectId 项目ID */ - const fetchVideoSegmentList = useCallback(async (projectId: string) => { - setProjectId(projectId); - try { - setLoading(true); - setError(null); - const response = await getShotList({ projectId }); - if (response.successful) { - setVideoSegmentList(response.data); - } else { - setError(`获取视频片段列表失败: ${response.message}`); - } - } catch (err) { - setError( - `获取视频片段列表失败: ${err instanceof Error ? err.message : "未知错误"}` - ); - } finally { - setLoading(false); - } - }, []); - - /** - * 选择视频片段并获取详情 - * @description 根据视频片段ID获取视频片段详情,并初始化相关的UseCase和数据 - * @param videoSegmentId 视频片段ID - */ - const selectVideoSegment = useCallback(async (videoSegmentId: string) => { - try { - setLoading(true); - setError(null); - - // 获取视频片段详情 - await fetchVideoSegmentList(projectId); - const videoSegmentEntity = videoSegmentList.find( - (videoSegment: VideoSegmentEntity) => videoSegment.id === videoSegmentId - ); - if (!videoSegmentEntity) { - setError(`视频片段不存在: ${videoSegmentId}`); - return; - } - const videoSegmentItem = new VideoSegmentItem(videoSegmentEntity); - setSelectedVideoSegment(videoSegmentItem); - - // 初始化UseCase - const newVideoSegmentEditUseCase = new VideoSegmentEditUseCase(videoSegmentItem); - setVideoSegmentEditUseCase(newVideoSegmentEditUseCase); - - // 从视频片段实体中获取草图数据和视频数据 - setVideoSegmentSketchData(videoSegmentEntity.sketchUrl || null); - setVideoSegmentVideoData(videoSegmentEntity.videoUrl || null); - } catch (err) { - setError( - `选择视频片段失败: ${err instanceof Error ? err.message : "未知错误"}` - ); - } finally { - setLoading(false); - } - }, []); - - /** - * 修改视频片段对话内容 - * @description 更新视频片段的对话内容,ContentItem数量和ID顺序不能变,只能修改content字段 - * @param newContent 新的对话内容数组 - */ - const updateVideoSegmentContentHandler = useCallback( - async (newContent: Array<{ roleId: string; content: string }>) => { - if (!videoSegmentEditUseCase) { - setError("视频片段编辑用例未初始化"); - return; - } - + const getVideoSegmentList = useCallback( + async (projectId: string): Promise => { try { setLoading(true); setError(null); - const updatedVideoSegment = await videoSegmentEditUseCase.updateVideoSegmentContent(newContent); - setSelectedVideoSegment(new VideoSegmentItem(updatedVideoSegment)); - } catch (err) { - setError( - `修改视频片段对话内容失败: ${ - err instanceof Error ? err.message : "未知错误" - }` - ); + + const segments = await shotEditUseCase.getVideoSegmentList(projectId); + setVideoSegments(segments); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "获取视频片段列表失败"; + setError(errorMessage); + console.error("获取视频片段列表失败:", error); } finally { setLoading(false); } }, - [videoSegmentEditUseCase] + [shotEditUseCase] ); - - /** - * 获取用户角色库 - * @description 获取当前用户的所有角色库数据 - */ - const fetchUserRoleLibrary = useCallback(async () => { - try { - setLoading(true); - setError(null); - const response = await getUserRoleLibrary(); - if (response.successful) { - setUserRoleLibrary(response.data); - } else { - setError(`获取用户角色库失败: ${response.message}`); - } - } catch (err) { - setError( - `获取用户角色库失败: ${err instanceof Error ? err.message : "未知错误"}` - ); - } finally { - setLoading(false); - } - }, []); - - /** - * 替换视频片段角色 - * @description 将视频片段中的某个角色替换为角色库中的另一个角色 - * @param oldRoleId 旧角色ID - * @param newRoleId 新角色ID - */ - const replaceVideoSegmentRoleHandler = useCallback( - async (oldRoleId: string, newRoleId: string) => { - if (!selectedVideoSegment) { - setError("未选择视频片段"); - return; - } - - try { - setLoading(true); - setError(null); - const response = await replaceShotRole({ - shotId: selectedVideoSegment.entity.id, - oldRoleId, - newRoleId, - }); - if (response.successful) { - // 重新获取视频片段详情 - await selectVideoSegment(selectedVideoSegment.entity.id); - } else { - setError(`替换视频片段角色失败: ${response.message}`); - } - } catch (err) { - setError( - `替换视频片段角色失败: ${err instanceof Error ? err.message : "未知错误"}` - ); - } finally { - setLoading(false); - } - }, - [selectedVideoSegment, selectVideoSegment] - ); - - /** - * 获取视频片段视频剧本内容 - * @description 获取视频片段视频的剧本内容,通过接口返回多个ScriptSliceEntity片段 - */ - const fetchVideoSegmentVideoScript = useCallback(async () => { - if (!selectedVideoSegment) { - setError("未选择视频片段"); - return; - } - - try { - setLoading(true); - setError(null); - const response = await getShotVideoScript({ - shotId: selectedVideoSegment.entity.id, - }); - if (response.successful) { - const script = new ScriptValueObject(response.data); - setVideoSegmentVideoScript([...script.scriptSlices]); - } else { - setError(`获取视频片段视频剧本失败: ${response.message}`); - } - } catch (err) { - setError( - `获取视频片段视频剧本失败: ${ - err instanceof Error ? err.message : "未知错误" - }` - ); - } finally { - setLoading(false); - } - }, [selectedVideoSegment]); - - /** - * 获取视频片段关联的角色信息 - * @description 获取当前视频片段可以使用的角色列表 - * @returns Promise 角色信息列表 - */ - const getVideoSegmentRoles = useCallback(async (): Promise => { - if (!videoSegmentEditUseCase) { - throw new Error("视频片段编辑用例未初始化"); - } - return await videoSegmentEditUseCase.getVideoSegmentRoles(); - }, [videoSegmentEditUseCase]); - - /** - * 获取视频片段关联的场景信息 - * @description 获取当前视频片段可以使用的场景列表 - * @returns Promise 场景信息列表 - */ - const getVideoSegmentScenes = useCallback(async (): Promise => { - if (!videoSegmentEditUseCase) { - throw new Error("视频片段编辑用例未初始化"); - } - return await videoSegmentEditUseCase.getVideoSegmentScenes(); - }, [videoSegmentEditUseCase]); - /** * 重新生成视频片段 - * @description 使用镜头、对话内容、角色ID替换参数、场景ID替换参数重新生成视频片段 - * @param shotPrompt 镜头描述 - * @param dialogueContent 对话内容 - * @param roleReplaceParams 角色ID替换参数,格式为{oldId:string,newId:string}[] - * @param sceneReplaceParams 场景ID替换参数,格式为{oldId:string,newId:string}[] + * @param shotPrompt 镜头描述数据 + * @param shotId 视频片段ID(可选) + * @param roleReplaceParams 角色替换参数(可选) + * @param sceneReplaceParams 场景替换参数(可选) + * @returns Promise 重新生成的视频片段 */ const regenerateVideoSegment = useCallback( async ( shotPrompt: LensType[], - dialogueContent: ContentItem[], - roleReplaceParams: { oldId: string; newId: string }[], - sceneReplaceParams: { oldId: string; newId: string }[] - ) => { - if (!videoSegmentEditUseCase) { - setError("视频片段编辑用例未初始化"); - return; - } - + shotId?: string, + roleReplaceParams?: { oldId: string; newId: string }[], + sceneReplaceParams?: { oldId: string; newId: string }[] + ): Promise => { try { setLoading(true); setError(null); - const updatedVideoSegment = await videoSegmentEditUseCase.regenerateVideoSegment( + + const regeneratedSegment = await shotEditUseCase.regenerateVideoSegment( shotPrompt, - dialogueContent, + shotId, roleReplaceParams, sceneReplaceParams ); - setSelectedVideoSegment(new VideoSegmentItem(updatedVideoSegment)); - } catch (err) { - setError( - `重新生成视频片段失败: ${err instanceof Error ? err.message : "未知错误"}` - ); + + // 如果重新生成的是现有片段,更新列表中的对应项 + if (shotId) { + setVideoSegments(prev => + prev.map(segment => + segment.id === shotId ? regeneratedSegment : segment + ) + ); + } else { + // 如果是新生成的片段,添加到列表中 + setVideoSegments(prev => [...prev, regeneratedSegment]); + } + + return regeneratedSegment; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "重新生成视频片段失败"; + setError(errorMessage); + console.error("重新生成视频片段失败:", error); + throw error; } finally { setLoading(false); } }, - [videoSegmentEditUseCase] + [shotEditUseCase] ); + /** + * AI优化视频内容 + * @param shotId 视频片段ID + * @param userRequirement 用户优化需求 + * @param lensData 镜头数据数组 + * @returns Promise 优化后的镜头数据 + */ + const optimizeVideoContent = useCallback( + async ( + shotId: string, + userRequirement: string, + lensData: LensType[] + ): Promise => { + try { + setLoading(true); + setError(null); + + const optimizedLensData = await shotEditUseCase.optimizeVideoContent( + shotId, + userRequirement, + lensData + ); + + // 注意:这里不再更新videoSegments状态,因为返回的是LensType[]而不是VideoSegmentEntity + // 调用者需要自己处理优化后的镜头数据 + + return optimizedLensData; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "AI优化视频内容失败"; + setError(errorMessage); + console.error("AI优化视频内容失败:", error); + throw error; + } finally { + setLoading(false); + } + }, + [shotEditUseCase] + ); + + /** + * 更新视频内容 + * @param shotId 视频片段ID + * @param content 新的对话内容 + * @returns Promise 更新后的视频片段 + */ + const updateVideoContent = useCallback( + async ( + shotId: string, + content: Array<{ roleId: string; content: string }> + ): Promise => { + try { + setLoading(true); + setError(null); + + const updatedSegment = await shotEditUseCase.updateVideoContent( + shotId, + content + ); + + // 更新列表中的对应项 + setVideoSegments(prev => + prev.map(segment => + segment.id === shotId ? updatedSegment : segment + ) + ); + + return updatedSegment; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "更新视频内容失败"; + setError(errorMessage); + console.error("更新视频内容失败:", error); + throw error; + } finally { + setLoading(false); + } + }, + [shotEditUseCase] + ); + + /** + * 中断当前操作 + */ + const abortOperation = useCallback((): void => { + shotEditUseCase.abortOperation(); + setLoading(false); + }, [shotEditUseCase]); + + /** + * 设置选中的视频片段 + */ + const setSelectedSegmentHandler = useCallback((segment: VideoSegmentEntity | null): void => { + setSelectedSegment(segment); + }, []); + + /** + * 清除错误信息 + */ + const clearError = useCallback((): void => { + setError(null); + }, []); + return { - // 响应式状态 - 用于UI组件订阅和渲染 - /** 视频片段列表 - 当前项目的所有视频片段数据 */ - videoSegmentList, - /** 当前选中的视频片段 - 包含视频片段实体和编辑状态 */ - selectedVideoSegment, - /** 当前视频片段的草图数据URL - 从视频片段实体中获取的草图图片链接 */ - videoSegmentSketchData, - /** 当前视频片段的视频数据URL - 从视频片段实体中获取的视频链接 */ - videoSegmentVideoData, - - /** 用户角色库 - 当前用户可用的所有角色数据 */ - userRoleLibrary, - /** 当前视频片段的视频剧本片段 - 通过接口获取的剧本内容 */ - videoSegmentVideoScript, - /** 加载状态 - 标识当前是否有异步操作正在进行 */ + // 响应式状态 loading, - /** 错误信息 - 记录最近一次操作的错误信息 */ + videoSegments, + selectedSegment, error, - - // 操作方法 - 提供给UI组件调用的业务逻辑方法 - /** 获取视频片段列表 - 根据项目ID获取所有视频片段数据 */ - fetchVideoSegmentList, - /** 选择视频片段并获取详情 - 选择指定视频片段并初始化相关数据 */ - selectVideoSegment, - /** 修改视频片段对话内容 - 更新视频片段的对话内容,保持ContentItem结构不变 */ - updateVideoSegmentContent: updateVideoSegmentContentHandler, - - /** 获取用户角色库 - 获取当前用户的所有角色数据 */ - fetchUserRoleLibrary, - /** 替换视频片段角色 - 将视频片段中的角色替换为角色库中的另一个角色 */ - replaceVideoSegmentRole: replaceVideoSegmentRoleHandler, - /** 获取视频片段视频剧本内容 - 通过接口获取视频剧本片段 */ - fetchVideoSegmentVideoScript, - /** 获取视频片段关联的角色信息 - 获取当前视频片段可用的角色列表 */ - getVideoSegmentRoles, - /** 获取视频片段关联的场景信息 - 获取当前视频片段可用的场景列表 */ - getVideoSegmentScenes, - /** 重新生成视频片段 - 使用新参数重新生成视频片段内容 */ + // 操作方法 + getVideoSegmentList, regenerateVideoSegment, + optimizeVideoContent, + updateVideoContent, + abortOperation, + setSelectedSegment: setSelectedSegmentHandler, + clearError, }; }; + +/** + * 使用示例: + * + * ```tsx + * import { useShotService } from '@/app/service/Interaction/ShotService'; + * + * const VideoSegmentComponent = () => { + * const { + * loading, + * videoSegments, + * selectedSegment, + * error, + * getVideoSegmentList, + * regenerateVideoSegment, + * optimizeVideoContent, + * updateVideoContent, + * setSelectedSegment, + * clearError + * } = useShotService(); + * + * // 获取视频片段列表 + * useEffect(() => { + * getVideoSegmentList('project-id'); + * }, [getVideoSegmentList]); + * + * // 重新生成视频片段 + * const handleRegenerate = async () => { + * try { + * await regenerateVideoSegment([ + * new LensType('特写', '镜头描述', '运镜') + * ]); + * } catch (error) { + * console.error('重新生成失败:', error); + * } + * }; + * + * // AI优化镜头数据 + * const handleOptimize = async () => { + * try { + * const optimizedLensData = await optimizeVideoContent( + * 'shot-id', + * '让镜头更加动态', + * [new LensType('特写', '当前镜头描述', '运镜')] + * ); + * + * // 处理优化后的镜头数据 + * console.log('优化后的镜头数据:', optimizedLensData); + * } catch (error) { + * console.error('优化失败:', error); + * } + * }; + * + * return ( + *
+ * {loading &&
加载中...
} + * {error &&
错误: {error}
} + * {videoSegments.map(segment => ( + *
+ *

{segment.name}

+ *

状态: {segment.status}

+ *
+ * ))} + *
+ * ); + * }; + * ``` + */ diff --git a/app/service/domain/Entities.ts b/app/service/domain/Entities.ts index 3783150..b0d80cc 100644 --- a/app/service/domain/Entities.ts +++ b/app/service/domain/Entities.ts @@ -68,18 +68,6 @@ export interface SceneEntity extends BaseEntity { generateTextId: string; } - -/**视频片段进度 */ -export enum VideoSegmentStatus { - /** 草稿加载中 */ - sketchLoading = 0, - /** 视频加载中 */ - videoLoading = 1, - /** 完成 */ - finished = 2, -} - - /** * 视频片段实体接口 */ @@ -90,18 +78,8 @@ export interface VideoSegmentEntity extends BaseEntity { sketchUrl: string; /**视频片段视频Url */ videoUrl: string[]; - /**视频片段状态 */ - status: VideoSegmentStatus; - /**角色ID列表 */ - roleList: string[]; - /**场景ID列表 */ - sceneList: string[]; - /**对话内容 */ - content: ContentItem[]; + /**视频片段状态 0:草稿加载中 1:视频加载中 2:完成 */ + status: 0 | 1 | 2; /**镜头项 */ lens: LensType[]; - /**视频片段剧本Id */ - scriptId: string; } - - diff --git a/app/service/domain/Item.ts b/app/service/domain/Item.ts index 7100277..a8f7239 100644 --- a/app/service/domain/Item.ts +++ b/app/service/domain/Item.ts @@ -111,23 +111,3 @@ export class SceneItem extends EditItem { super(entity, metadata); } } - -/** - * 视频片段可编辑项 - */ -export class VideoSegmentItem extends EditItem { - type: ItemType = ItemType.IMAGE; - - constructor( - entity: VideoSegmentEntity, - metadata: Record = {} - ) { - super(entity, metadata); - } - /** - * 更新为视频状态 - */ - updateToVideoStatus(): void { - this.type = ItemType.VIDEO; - } -} diff --git a/app/service/domain/valueObject.ts b/app/service/domain/valueObject.ts index 6aa73f1..20e7eae 100644 --- a/app/service/domain/valueObject.ts +++ b/app/service/domain/valueObject.ts @@ -1,6 +1,5 @@ /**不同类型 将有不同元数据 */ - export enum ScriptSliceType { /** 文本 */ text = "text", @@ -54,25 +53,11 @@ export class ScriptSlice { * 对话内容项值对象 * @description 代表对话中的一个内容项,按值相等判断,不可变 */ -export class ContentItem { - /** 角色ID */ - readonly roleId: string; +export interface ContentItem { + /** 角色名称 */ + roleName: string; /** 对话内容 */ - readonly content: string; - - constructor(roleId: string, content: string) { - this.roleId = roleId; - this.content = content; - } - - /** - * 值对象相等性比较 - * @param other 另一个ContentItem实例 - * @returns 是否相等 - */ - equals(other: ContentItem): boolean { - return this.roleId === other.roleId && this.content === other.content; - } + content: string; } /** @@ -80,30 +65,17 @@ export class ContentItem { * @description 代表镜头信息,按值相等判断,不可变 */ export class LensType { - /** 镜头名称 */ - readonly name: string; - /** 镜头描述 */ - readonly content: string; - /**运镜描述 */ - readonly movement: string; + /**镜头名称 */ + readonly name: string; + /**镜头描述 */ + readonly script: string; + /**对话内容 */ + readonly content: ContentItem[]; - constructor(name: string, content: string, movement: string) { + constructor(name: string, script: string, content: ContentItem[]) { this.name = name; + this.script = script; this.content = content; - this.movement = movement; - } - - /** - * 值对象相等性比较 - * @param other 另一个LensType实例 - * @returns 是否相等 - */ - equals(other: LensType): boolean { - return ( - this.name === other.name && - this.content === other.content && - this.movement === other.movement - ); } } @@ -176,13 +148,15 @@ export class 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" + "The Inciting Incident", + ); // --- 其他字段的提取保持不变,使用标题进行查找 --- const categories = this.extractContentByHeader(text, "GENRE") @@ -190,7 +164,6 @@ export class StoryDetails { .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"); @@ -209,6 +182,18 @@ export class StoryDetails { stakes, characterArc, }; + } catch (error) { + return { + synopsis: "", + categories: [], + protagonist: "", + incitingIncident: "", + problem: "", + conflict: "", + stakes: "", + characterArc: "", + }; + } } /** * 辅助方法:根据标题提取其下的内容,并去除所有Markdown标签。 @@ -222,7 +207,7 @@ export class StoryDetails { debug: boolean = false ): string { try { - debug&& console.log(`正在查找标题: "${headerName}"`); + debug && console.log(`正在查找标题: "${headerName}"`); // 转义正则表达式中的特殊字符 const escapeRegex = (text: string): string => { @@ -304,11 +289,12 @@ export class StoryDetails { if (match && match[1] && match[1].trim()) { content = match[1].trim(); debug && console.log(`使用模式 ${i + 1} 匹配成功`); - debug && console.log(`匹配的完整内容: "${match[0].substring(0, 150)}..."`); + debug && + console.log(`匹配的完整内容: "${match[0].substring(0, 150)}..."`); break; } } - + // console.log(headerName, content) if (!content) { debug && console.log(`没有找到标题 "${headerName}" 对应的内容`); // 尝试更宽松的匹配作为最后手段 @@ -336,10 +322,10 @@ export class StoryDetails { return ""; } - debug && console.log(`清理后的内容: "${content.substring(0, 100)}..."`); + debug && console.log(`清理后的内容: "${content.substring(0, 100)}..."`); return content; } catch (error) { - console.error("内容提取出错:", error); + console.log("内容提取出错:", error); return ""; } } @@ -400,4 +386,3 @@ export class ScriptValueObject { return this.scriptText; } } - diff --git a/app/service/test/Script.test.ts b/app/service/test/Script.test.ts index efe0861..b848401 100644 --- a/app/service/test/Script.test.ts +++ b/app/service/test/Script.test.ts @@ -10,9 +10,14 @@ global.localStorage = { // Mock BASE_URL jest.mock("../../../api/constants", () => ({ - BASE_URL: "http://127.0.0.1:8000", + BASE_URL: "https://77.smartvideo.py.qikongjian.com", })); - +import { + getProjectScript, + abortVideoTask, + pausePlanFlow, + resumePlanFlow, +} from "../../../api/video_flow"; import {StoryDetails } from "../domain/valueObject"; import { ScriptEditUseCase } from "../usecase/ScriptEditUseCase"; @@ -37,7 +42,7 @@ describe("ScriptService 业务逻辑测试", () => { const createRes = await newScriptEditUseCase.createProject( script, "user123", - "auto", + "automatic", "720p", "en" ); @@ -56,7 +61,7 @@ describe("ScriptService 业务逻辑测试", () => { }); describe("解析测试", () => { - const scriptText = `**Core Elements**\n\n1. **Protagonist:**\n * **Core Identity:** AKIO, a young, spirited male Shiba Inu. His fur is a perfect toasted sesame color. He is naive, driven by simple impulses, and possesses an almost comical level of competitive spirit. He lives a comfortable life near a bustling seaside pier in the present day.\n * **Initial State & Flaw:** Akio begins the story content and carefree, enjoying the sun. His critical flaw is a combination of gluttony and a naive obsession with winning. He believes victory, in any form, is the ultimate measure of happiness and worth, failing to see the emptiness of a prize won without purpose.\n\n2. **The Inciting Incident:**\n * A large, forgotten plate of golden french fries is left on a low crate on the pier. His pier rival, HANA, a sleek red Shiba, spots it at the same moment. An unspoken challenge flashes between them. QUEENIE, an elegant Siamese cat who rules the pier, leaps atop a tall piling, implicitly agreeing to officiate. The contest is suddenly, irrevocably on. This event shatters Akio's peaceful morning and directly triggers his competitive flaw.\n\n3. **The Problem & New Goal:**\n * The problem is brutally simple: How can I eat more fries than Hana and win this contest? This gives Akio a new, all-consuming goal: to achieve victory and be crowned the undisputed champion of the fries.\n\n4. **Conflict & Obstacles:**\n * **Primary Conflict:** Akio vs. Hana in a direct, head-to-head eating competition. Internally, Akio battles his growing physical misery against his desperate, ingrained need to win.\n * **Key Obstacles:**\n 1. Hana is an equally matched and fiercely determined opponent.\n 2. The sheer volume of fries turns the contest from a sprint into a grueling marathon, pushing them both to their physical limits.\n\n5. **The Stakes:**\n * **What is at stake?:** If Akio wins, he gains temporary glory and the satisfaction of his ego. If he fails, he loses face to his rival. However, the true stake is what he stands to permanently lose if he *succeeds* through pure gluttony: he will lose the simple joy of food, his physical well-being, and the potential for a genuine connection with Hana, leaving him bloated and alone with a hollow victory.\n\n6. **Character Arc Accomplished:**\n * By the end, faced with the final fry, Akio overcomes his flaw. He sees the contest's absurdity and Hana's shared suffering. He chooses connection over competition, transforming from a mindless glutton into a creature capable of empathy and grace. He learns that sharing a moment is more fulfilling than winning a prize.\n\n**GENRE:** Sports\n\n---\n\n**SCENE 1**\n\n**[SCENE'S CORE CONFLICT]: The thrill of the challenge and the start of an epic, absurd competition.**\n\nEXT. PIER - DAY\n\n**Scene Transition:** The scene opens on a perfect, sun-drenched morning, establishing the peaceful status quo that is about to be shattered.\n\nThe air is salty and bright. Wooden planks, bleached by the sun, stretch out over gently lapping water. AKIO, a handsome Shiba Inu, basks in a patch of sun, tail curled, eyes blissfully shut.\n\nA WIDE SHOT establishes the key players in their starting positions. A few yards away, HANA, a fiery red Shiba, watches a seagull. On a high wooden piling, QUEENIE, a regal Siamese cat, grooms a paw with aristocratic disdain. On a nearby railing, two PIGEONS, GUS and GERTIE, coo softly.\n\nA careless tourist leaves a large paper plate piled high with golden, glistening FRENCH FRIES on a low wooden crate.\n\nCLOSE-UP on Akio's nose twitching. His eyes snap open. At the same instant, Hana turns. Their eyes lock. The air crackles.\n\nQueenie stops grooming. She stares down at them, then at the fries. With a deliberate, fluid motion, she sits perfectly upright, tail wrapped around her paws. The judge has taken her seat.\n\nAkio's tail begins to thump against the wood. A low, excited growl rumbles in his chest. Hana answers with a sharp, challenging yip.\n\n**AKIO (V.O.)**\nThis was it. The big one.\nGlory was on that plate.\n\nWithout another sound, they both lunge.\n\nRAPID CUTS between Akio and Hana gobbling fries. Their muzzles are a blur of motion. Golden sticks disappear. Tails wag like furious metronomes. The pigeons lean forward, heads cocked, their cooing becoming more rapid and excited.\n\n**GUS & GERTIE**\n(Excited cooing)\n(He's fast! But she's relentless!)\n\nQueenie watches, impassive. She lifts a single paw, as if about to signal a foul, then slowly lowers it. She will allow it. For now.\n\n**AKIO (V.O.)**\nShe was good. Real good.\nBut I was born for this.\n\n---\n\n**SCENE 2**\n\n**[SCENE'S CORE CONFLICT]: The escalation of the contest into an absurd, grueling marathon.**\n\nEXT. PIER - MIDDAY\n\n**Scene Transition:** A time-lapse montage shows the sun climbing higher in the sky. The shadows on the pier shrink. The pile of fries on the plate is noticeably smaller, but a new, larger pile of discarded, half-eaten fries is growing on the planks around it. The transition emphasizes the passage of time and the sheer endurance of the contest.\n\nThe midday sun beats down. The initial frenzy has subsided into a grim, rhythmic battle of attrition. Akio and Hana are no longer gobbling; they are CHEWING. Methodically. Grimly. Their faces are greasy. Their bellies are visibly distended.\n\n**AKIO (V.O.)**\nThe thrill was gone.\nNow, it was just a job.\nOne fry. Then the next.\n\nHana pauses, breathing heavily. A single fry hangs from her mouth. She looks at Akio. There is no fire in her eyes now, only exhaustion. Akio meets her gaze, his own jaw working slowly.\n\nQueenie yawns, a flash of pink mouth and sharp teeth. She looks utterly bored. She stretches one leg, then the other, before settling back into her judicial pose.\n\n**QUEENIE**\n(A low, bored meow)\n(Are they still going? Honestly.)\n\nGus and Gertie are still rapt. They hop from one foot to the other, nudging each other, offering quiet commentary.\n\n**GUS & GERTIE**\n(Murmuring coos)\n(His form is slipping. Look.)\n(She has more stamina. Classic.)\n\nAkio swallows with a gulp. He eyes the remaining pile. It still looks like a mountain. He glances at Hana. She takes a deep breath and doggedly chomps another fry.\n\n**AKIO (V.O.)**\nI couldn't quit. Not now.\nChampions don't quit. Right?\n\nHe forces another fry into his mouth. It tastes like cardboard and regret.\n\n---\n\n**SCENE 3**\n\n**[SCENE'S CORE CONFLICT]: The physical and emotional breaking point, where the cost of victory becomes clear.**\n\nEXT. PIER - SUNSET\n\n**Scene Transition:** The scene opens on an empty shot of the sun, a brilliant orange orb, touching the horizon. The water is a sheet of molten gold. The light is warm but fading, casting long, dramatic shadows. This transition marks the end of the day and the climax of the struggle.\n\nThe pier is bathed in the golden hour's glow. The scene is quiet, the earlier energy completely gone. Akio and Hana are lying on the planks, flanking the plate. They are panting, their sides heaving. The mountain of fries is gone.\n\nOnly ONE. SINGLE. FRY remains.\n\nIt sits perfectly in the center of the greasy plate, a final, golden trophy.\n\nAkio lifts his head. It feels like it weighs a thousand pounds. He looks at the fry, then at Hana. She is a mirror of his own misery. Her fur is matted with grease, her eyes are glassy. A tiny, pathetic whimper escapes her.\n\n**AKIO (V.O.)**\nWe had done it. We ate it all.\nBut there was no cheering.\nJust... this. This quiet ache.\n\nQueenie looks down, a flicker of something—pity? annoyance?—in her blue eyes. She lets out a soft, exasperated sigh.\n\n**QUEENIE**\n(A soft, tired meow)\n(Oh, for heaven's sake. Finish it.)\n\nAkio summons the last of his strength. He begins to drag himself towards the plate. Every muscle screams. This is the final push. The winning point. He can taste victory. It tastes like salt and exhaustion.\n\nHe reaches the plate, his nose inches from the final fry.\n\n---\n\n**SCENE 4**\n\n**[SCENE'S CORE CONFLICT]: The resolution through an act of grace, redefining victory as connection.**\n\nEXT. PIER - DUSK\n\n**Scene Transition:** Continuing directly from the previous scene's climax. The final rays of sunlight disappear, and the cool, soft light of dusk settles over the pier. The shift in light mirrors Akio's internal shift from the fiery heat of competition to the cool clarity of realization.\n\nAkio stares at the last fry. The golden light has faded, and the fry looks pale and unappetizing under the blue twilight.\n\nCLOSE-UP on Akio's eyes. We see the reflection of the lonely fry. Then his eyes shift, looking past it, towards Hana. She hasn't moved. She just lies there, defeated and miserable.\n\n**AKIO (V.O.)**\nAnd then I saw it.\nWinning meant she lost.\nWhat kind of prize was that?\n\nA profound change comes over Akio's face. The grim determination melts away, replaced by a soft, clear understanding.\n\nSLOW MOTION as Akio gently nudges the final fry with his nose. He doesn't eat it. He pushes it, slowly, deliberately, across the greasy plate until it stops directly in front of Hana's nose.\n\nHana's eyes flutter open. She looks at the fry, then at Akio. Confusion, then dawning surprise.\n\nAkio gives a small, tired tail wag. A real one. Not the frantic wag of competition, but a gentle wave of peace.\n\nHana looks at the fry for a long moment. Then she ignores it completely. She inches forward and gently licks Akio's nose.\n\nOn the piling, Queenie watches this. For the first time, a genuine, soft smile seems to grace her feline features. She lets out a quiet, approving purr.\n\n**QUEENIE**\n(A soft, rumbling purr)\n(Finally.)\n\nGus and Gertie coo softly, a gentle, contented sound. They bob their heads in unison, as if applauding.\n\nFADE OUT on the two Shibas, lying side-by-side in the twilight, the single, uneaten fry sitting between them like a forgotten trophy.\n\n**AKIO (V.O.)**\nWe didn't have a winner.\nWe had something better.\n\n---\n\n### Part 2: Addendum for Director & Performance\n\n**【Decoding the Directing Style】**\n\n* **Core Visual Tone:** A mock-epic \"sports documentary\" style. Use grand, sweeping shots in the beginning (like an NFL Films production) contrasted with gritty, handheld close-ups during the \"mid-game\" struggle. The color palette should shift dramatically: vibrant, saturated colors in Scene 1; a harsh, overexposed look in Scene 2; a warm, elegiac \"magic hour\" glow in Scene 3; and a cool, peaceful blue/purple palette for the resolution in Scene 4.\n* **Key Scene Treatment Suggestion:** For the climax in Scene 3, when only one fry remains, the sound design should drop out almost completely. All we hear is the labored breathing of the dogs, the gentle lapping of water, and the distant cry of a gull. The camera should be at a very low angle, making the single fry on the plate look like a monumental obelisk. The final action in Scene 4—Akio pushing the fry—should be captured in a single, unbroken take, focusing on the slow, deliberate movement and Hana's dawning reaction.\n\n**【The Core Performance Key】**\n\n* **The Character's Physicality:** Akio's physicality must arc. He starts with a bouncy, \"on the balls of his feet\" energy. In Scene 2, his movements become heavy and sluggish, his chewing laborious. By Scene 3, he should be practically immobile, every movement an immense effort. His final act of pushing the fry should be gentle and tender, a stark contrast to the frantic gobbling at the start.\n* **Subtextual Drive:** Akio's internal voiceover is the text; the subtext is in his eyes and body. His V.O. in Scene 2 says \"Champions don't quit,\" but his eyes should scream, \"I want my mommy.\" The subtext of the final scene is a silent apology and an offering of peace. When Hana licks his nose, the subtext is \"I accept. We're okay.\"\n\n**【Connection to the Zeitgeist】**\n\n* This story of a pointless, all-consuming competition for a meaningless prize serves as a gentle parable for the modern obsession with \"winning\" on social media and in hustle culture, suggesting that true fulfillment is found not in victory, but in shared humanity and connection.`; + const scriptText = `"**Logline**\nAn anxious young man, facing what he believes is a crushing public rejection on a first date, must overcome his deepest fear of inadequacy to take a chance on a real connection.\n\n**Core Elements**\n\n1. **Protagonist:**\n * **Core Identity:** LI WEI (李伟), 25, male, an introverted and socially anxious programmer in a modern metropolis. He is neat but unremarkable, hiding his insecurity behind a pair of glasses and a quiet demeanor.\n * **Initial State & Flaw:** He is at a coffee shop for a first date, fraught with nervous anticipation. His core flaw is a profound **fear of inadequacy and rejection**, a deep-seated belief that he is fundamentally uninteresting and will inevitably disappoint others.\n\n2. **The Inciting Incident:**\n * (State Clearly) A beautiful, confident woman, XIAOQIAN (小倩), whom he believes is his date, enters the coffee shop. However, she sits at a different table and is soon joined by a handsome, charismatic man. Their immediate, easy chemistry confirms Li Wei's worst fears. He has been stood up for someone better, or so he believes. This event is a passive, public humiliation that directly preys on his core flaw.\n\n3. **The Problem & New Goal:**\n * (State Clearly) The problem is no longer about impressing a date. It is a crushing psychological crisis: \"How do I escape this situation without confirming my own deeply-held belief that I am a failure?\" His new, urgent goal is to simply survive the moment and leave with his self-worth intact.\n\n4. **Conflict & Obstacles:**\n * **Primary Conflict:** An intense internal battle between Li Wei's crippling self-doubt and the flicker of a desire to not be defined by it.\n * **Key Obstacles:** 1) The happy couple's laughter, which serves as an auditory torment. 2) The well-meaning WAITRESS, whose attention amplifies his isolation.\n\n5. **The Stakes:**\n * **What is at stake?:** If he succeeds in leaving with composure, he salvages a fragile piece of his dignity. If he fails—fleeing awkwardly or breaking down—he will permanently cement his narrative of failure, making it exponentially harder to ever attempt a social connection again. He will lose the courage to try.\n\n6. **Character Arc Accomplished:**\n * (State the Result Clearly) He confronts his fear of inadequacy head-on. After receiving a text from his *actual* date, who is at the wrong location, he makes a conscious choice. Instead of succumbing to his anxiety and cancelling, he takes a small but monumental step. He deletes a self-deprecating draft, sends a simple, confident reply, and walks out, ready to face the unknown. He has transformed from someone paralyzed by fear into someone willing to act despite it.\n\n**GENRE:** Drama\n\n---\n\n**SCENE 1**\n\nINT. \"CORNER PERCH\" COFFEE SHOP - DAY\n\n**Scene Transition:** An establishing shot of a sun-drenched, stylishly minimalist coffee shop. The camera finds our protagonist, LI WEI, already seated, having arrived early to stake out a \"safe\" spot. The scene is set to capture the quiet, hopeful tension before an anticipated event.\n\nSOUND of distant traffic, the gentle hiss of an espresso machine, soft indie music\n\nThe afternoon sun streams through a large window, illuminating dust motes dancing in the air. The coffee shop is a haven of warm wood and the rich aroma of roasted beans.\n\nLI WEI (25), neat in a plain grey t-shirt, sits alone at a small two-person table. He polishes his glasses with a napkin, an unnecessary, repetitive motion.\n\nAn untouched glass of water sweats onto the table.\n\nCLOSE-UP - LI WEI'S PHONE\nHis thumb hovers over a messaging app. The profile picture of his date, \"XIAOQIAN,\" is a bright, smiling face. He types a message: \"I'm here. At the table by the window.\" He hesitates. Deletes it.\n\nHe forces a casual posture, trying to look absorbed in his phone, but his eyes dart towards the door every time it chimes.\n\n**LI WEI (V.O.)**\nJust be normal. Don't look desperate.\nJust... a guy enjoying coffee. Alone.\n\nHe picks up his water glass, but his hand trembles slightly, rattling the ice. He puts it down too quickly. A little water sloshes over the rim. He dabs at it furiously with a napkin.\n\n---\n\n**SCENE 2**\n\nINT. \"CORNER PERCH\" COFFEE SHOP - DAY\n\n**Scene Transition:** The chiming of the door from the previous scene directly leads to the entrance of a new character. Li Wei's focus, and therefore the camera's, immediately shifts from his internal world to this external arrival, escalating the scene's tension.\n\nThe bell on the door CHIMES.\n\nLi Wei's head snaps up.\n\nXIAOQIAN (24) enters. She is exactly like her profile picture—radiant, dressed in a stylish floral dress. She scans the room.\n\nLi Wei's heart hammers. He makes to raise a hand, a small, aborted wave.\n\nBut her eyes glide right past him. She smiles, a flash of genuine warmth, at a table across the room. She walks over to an empty table for two, not his.\n\nLi Wei's half-raised hand drops to the table with a soft THUD. His face freezes.\n\nHe watches, paralyzed, as she sits down, places her bag on the empty chair, and pulls out her phone. She is waiting.\n\nThe WAITRESS (40s, kindly) approaches Li Wei's table.\n\n**WAITRESS**\nReady to order something else, dear?\nOr still waiting for your friend?\n\n**LI WEI**\n(A whisper)\nStill waiting. Thank you.\n\nThe waitress nods sympathetically and moves away. The words hang in the air, a public declaration of his solitude.\n\nA moment later, the door CHIMES again. A HANDSOME MAN (26), confident and laughing, enters and walks directly to Xiaoqian's table.\n\n**HANDSOME MAN**\nSorry! The traffic was a nightmare.\nYou look amazing.\n\n**XIAOQIAN**\n(Laughing)\nYou're lucky you're so handsome.\nI almost left.\n\nThey lean in, their conversation a low, happy murmur. Their chemistry is instant, effortless.\n\nEXTREME CLOSE-UP - LI WEI'S FACE\nA universe of humiliation plays out in his eyes. He stares down at his phone, the screen now a black mirror reflecting his own strained face.\n\n---\n\n**SCENE 3**\n\nINT. \"CORNER PERCH\" COFFEE SHOP - DAY\n\n**Scene Transition:** Li Wei remains frozen, trapped by the previous scene's devastating turn. The camera stays tight on him, emphasizing his psychological confinement. The passage of time is marked only by the shifting light and the sounds from the other table, creating a slow, agonizing development of his internal state.\n\nSOUND of the couple's intermittent laughter, a sharp, painful punctuation mark.\n\nMinutes crawl by. Li Wei doesn't move. He scrolls aimlessly through his phone, not seeing anything. The screen's glow is the only light on his face, which has gone pale.\n\nHe risks a glance. The couple is now sharing a piece of cake, laughing as the man wipes a smudge of cream from her nose. It is a moment of perfect, casual intimacy.\n\nLi Wei flinches as if struck. He needs to escape.\n\nHe starts drafting a text to a friend.\n\"Worst day ever. You won't believe...\" He stops. Deletes it. The shame is too much to even articulate.\n\n**LI WEI (V.O.)**\nJust go. Get up and walk out.\nWhy can't you just go?\n\nHis body won't obey. He is pinned to his chair by the weight of his own failure. He imagines walking past them, feeling their eyes on his back. The thought is unbearable.\n\nSuddenly, his phone VIBRATES violently on the table. A sharp, loud BUZZ that cuts through the cafe's murmur.\n\nHe startles. The couple looks over for a second. Li Wei's face burns with shame.\n\nHe looks at the screen. A new message. From a number he doesn't recognize, but the profile picture is different. More candid, less polished, but still her. The name reads: \"Qianqian.\"\n\nTHE MESSAGE: \"Hey! So sorry, I'm an idiot! I'm at the 'Corner Cup' not the 'Corner Perch'! They're across the street from each other! Running over now, so so sorry! :( \"\n\nLi Wei stares at the text. His brain reboots.\nThe 'Corner Cup'. Not the 'Corner Perch'.\nThis wasn't rejection. It was... logistics.\n\nA wave of dizzying relief washes over him, followed immediately by a new surge of anxiety. He has to meet her now. After all this.\n\nHe opens a reply, his thumbs shaking.\n\"It's okay but honestly I was about to leave, this has been a really awkward...\"\n\nHe stops. He looks at the words on his screen. A story of defeat.\nHe looks up, past his phone, at the couple across the room. They are just two people. Happy, yes. But just people.\nHe looks at the new message from \"Qianqian.\" An apology. An emoji. A human mistake.\n\nHe slowly, deliberately, deletes his entire draft.\n\n---\n\n**SCENE 4**\n\nINT. \"CORNER PERCH\" COFFEE SHOP - DAY\n\n**Scene Transition:** The decision made at the end of Scene 3 provides the direct motivation for action. The camera pulls back, giving Li Wei space for the first time. His movement from the chair to the door is the scene's entire narrative arc—a physical journey mirroring his internal one.\n\nLi Wei takes a deep breath. It's the first real breath he's taken all day.\n\nCLOSE-UP - HIS THUMBS\nHe types a new message. Short. Simple.\n\nREPLY: \"No worries. I'm at the window. See you soon.\"\n\nHe hits send without hesitating.\n\nThen, he does the impossible. He stands up. He pushes his chair in neatly. He places a few bills on the table for the water.\n\nHe walks towards the door.\nHis path takes him directly past the other couple's table.\nFor a fleeting second, he almost looks at them. But he doesn't. He looks straight ahead, at the door, at the world outside. They are no longer the center of his universe. They are just background noise.\n\nHe pushes the door open. The bell CHIMES, this time a sound of release.\n\nEXT. STREET - DAY\n\n**Scene Transition:** An \"empty shot\" transition. The camera holds on the 'Corner Perch' door as it closes, then PANS across the busy street to the bright, welcoming sign of the 'Corner Cup' cafe on the other side. This visually connects the two locations and bridges the emotional journey.\n\nLi Wei steps out into the bright, noisy street. The sun hits his face. He blinks, adjusting to the light. He looks across the street at the other cafe.\n\nHe takes a step off the curb.\n\n**LI WEI (V.O.)**\nJust a guy.\nGoing to get a coffee.\n\nHe walks across the street, his stride not confident, not perfect, but steady. He is moving forward.\n\nFADE TO BLACK.\n\n---\n\n### Part 2: Addendum for Director & Performance\n\n**【Decoding the Directing Style】**\n\n* **Core Visual Tone:** Employ a style of urban realism with high subjectivity. The color palette inside the 'Perch' should be warm, almost oppressively so, to contrast with Li Wei's cold anxiety. Use a shallow depth of field to isolate Li Wei, blurring the rest of the cafe into an indistinct background until the other couple becomes his sharp, painful focus.\n* **Key Scene Treatment Suggestion:** In Scene 3, during Li Wei's paralysis, use a long, static take. The only movement should be his thumbs on the phone and his darting eyes. The sound design is crucial here: amplify the couple's laughter and the clinking of their silverware until it feels like a psychological attack. The sudden, loud vibration of his phone should be a jump-scare, shattering the agonizing stillness and jolting both him and the audience.\n\n**【The Core Performance Key】**\n\n* **The Character's Physicality:** Li Wei's body language should be a portrait of containment. His shoulders are slightly hunched, his movements are small and economical as if trying to take up less space. In Scene 1, his fidgeting with the napkin and glass isn't just nervousness; it's a desperate attempt to control an environment he feels has no place for him. His transformation in Scene 4 is marked by the simple act of standing up straight and walking with a directed purpose, not looking down.\n* **Subtextual Drive:** The subtext of Li Wei's internal monologue and actions until the final text is: \"Confirm my inadequacy so I can go home.\" He is subconsciously looking for proof that he should give up. The performance must show this self-sabotage. The final text message, \"See you soon,\" has the subtext: \"I am willing to be proven wrong about myself.\"\n\n**【Connection to the Zeitgeist】**\n\n* This story of perceived public failure and digital miscommunication resonates deeply in an age of curated online identities, where the gap between our idealized digital selves and our anxious, real-world selves creates constant, quiet friction."`; let protagonistText = "", incitingIncidentText = "", @@ -78,8 +83,8 @@ describe("解析测试", () => { categories = s .extractContentByHeader(scriptText, "GENRE") .split(",") - .map((s) => s.trim()) - .filter((s) => s.length > 0); + // .map((s) => s.trim()) + // .filter((s) => s.length > 0); console.log("categories", categories); protagonist = s.extractContentByHeader(scriptText, "Core Identity:"); console.log("protagonist", protagonist); @@ -91,3 +96,13 @@ describe("解析测试", () => { console.log("stakes", stakes); }); }); + + +describe("剧本功能对接测试",()=>{ + it("初始化 解析剧本" , async()=>{ + const response = await getProjectScript({ project_id: "21f194df-cb4b-4e3a-8d44-ca14f23fd1c2" }); + console.log(response.data.generated_script); + const newScriptEditUseCase = new ScriptEditUseCase(response.data.generated_script); + console.log(newScriptEditUseCase.getStoryDetails()); + }) +} ) diff --git a/app/service/usecase/ScriptEditUseCase.ts b/app/service/usecase/ScriptEditUseCase.ts index 20582bb..0856324 100644 --- a/app/service/usecase/ScriptEditUseCase.ts +++ b/app/service/usecase/ScriptEditUseCase.ts @@ -74,8 +74,6 @@ export class ScriptEditUseCase { * @returns Promise */ async enhanceScript( - newScript: string|string[], - key: ScriptEditKey, aiOptimizing: string, stream_callback?: (data: any) => void ): Promise { @@ -93,7 +91,6 @@ export class ScriptEditUseCase { await enhanceScriptStream( { original_script: originalScript, - [key]: newScript, aiOptimizing, }, (content) => { diff --git a/app/service/usecase/ShotEditUsecase.ts b/app/service/usecase/ShotEditUsecase.ts index 3ccae1f..8d779c7 100644 --- a/app/service/usecase/ShotEditUsecase.ts +++ b/app/service/usecase/ShotEditUsecase.ts @@ -1,204 +1,173 @@ -import { VideoSegmentEntity, RoleEntity, SceneEntity, AITextEntity, TagEntity } from '../domain/Entities'; -import { ContentItem, LensType } from '../domain/valueObject'; -import { VideoSegmentItem, RoleItem, SceneItem, TextItem, TagItem } from '../domain/Item'; +import { VideoSegmentEntity } from "../domain/Entities"; +import { ContentItem, LensType } from "../domain/valueObject"; import { - getShotRoles, - getShotScenes, - getShotData, + getShotList, regenerateShot, - updateShotContent -} from '@/api/video_flow'; + updateShotContent, + optimizeShotContent, +} from "@/api/video_flow"; /** * 视频片段编辑用例 * 负责视频片段内容的初始化、修改和优化 */ export class VideoSegmentEditUseCase { - constructor(private videoSegmentItem: VideoSegmentItem) { - } + private loading: boolean = false; + private abortController: AbortController | null = null; /** - * 获取视频片段关联的角色信息列表 - * @description 获取当前视频片段可以使用的角色列表 - * @returns Promise 角色信息列表 - * @throws {Error} 当API调用失败时抛出错误 + * @description 获取视频片段列表 + * @param projectId 项目ID + * @returns Promise 视频片段列表 */ - async getVideoSegmentRoles(): Promise { - const videoSegmentId = this.videoSegmentItem.entity.id; + async getVideoSegmentList(projectId: string): Promise { + try { + this.loading = true; - if (!videoSegmentId) { - throw new Error('视频片段ID不存在,无法获取角色信息'); - } + const response = await getShotList({ projectId }); - const response = await getShotRoles({ - shotId: videoSegmentId - }); + if (!response.successful) { + throw new Error(response.message || "获取视频片段列表失败"); + } - if (response.successful) { - return response.data; - } else { - throw new Error(`获取视频片段角色信息失败: ${response.message}`); + return response.data || []; + } catch (error) { + console.error("获取视频片段列表失败:", error); + throw error; + } finally { + this.loading = false; } } /** - * 获取视频片段关联的场景信息列表 - * @description 获取当前视频片段可以使用的场景列表 - * @returns Promise 场景信息列表 - * @throws {Error} 当API调用失败时抛出错误 - */ - async getVideoSegmentScenes(): Promise { - const videoSegmentId = this.videoSegmentItem.entity.id; - - if (!videoSegmentId) { - throw new Error('视频片段ID不存在,无法获取场景信息'); - } - - const response = await getShotScenes({ - shotId: videoSegmentId - }); - - if (response.successful) { - return response.data; - } else { - throw new Error(`获取视频片段场景信息失败: ${response.message}`); - } - } - - /** - * 重新获取当前视频片段信息 - * @description 从服务器重新获取当前视频片段的详细数据,并更新当前实体 - * @returns Promise<{ text: AITextEntity; tags: TagEntity[] }> 视频片段相关的AI文本和标签数据 - * @throws {Error} 当API调用失败时抛出错误 - */ - async refreshVideoSegmentData(): Promise<{ text: AITextEntity; tags: TagEntity[] }> { - const videoSegmentId = this.videoSegmentItem.entity.id; - - if (!videoSegmentId) { - throw new Error('视频片段ID不存在,无法获取视频片段数据'); - } - - const response = await getShotData({ - shotId: videoSegmentId - }); - - if (response.successful) { - // 更新当前视频片段的实体数据 - const { text, tags } = response.data; - - // 更新视频片段实体中的相关字段 - const updatedVideoSegmentEntity = { - ...this.videoSegmentItem.entity, - generateTextId: text.id, // 更新AI文本ID - tagIds: tags.map((tag: TagEntity) => tag.id), // 更新标签ID列表 - updatedAt: Date.now(), // 更新时间戳 - }; - - // 更新当前UseCase中的实体 - this.videoSegmentItem.setEntity(updatedVideoSegmentEntity); - // 检查状态是否需要更新为视频状态 - this.checkAndUpdateVideoStatus(updatedVideoSegmentEntity); - - return response.data; - } else { - throw new Error(`获取视频片段数据失败: ${response.message}`); - } - } - - /** - * 重新生成当前视频片段 - * @description 使用镜头、对话内容、角色ID替换参数、场景ID替换参数重新生成视频片段 - * @param shotPrompt 镜头描述 - * @param dialogueContent 对话内容 - * @param roleReplaceParams 角色ID替换参数,格式为{oldId:string,newId:string}[] - * @param sceneReplaceParams 场景ID替换参数,格式为{oldId:string,newId:string}[] - * @returns Promise 重新生成的视频片段实体 - * @throws {Error} 当API调用失败时抛出错误 + * @description 通过视频镜头描述数据重新生成视频 + * @param shotPrompt 镜头描述数据 + * @param shotId 视频片段ID(可选,如果重新生成现有片段) + * @param roleReplaceParams 角色替换参数(可选) + * @param sceneReplaceParams 场景替换参数(可选) + * @returns Promise 重新生成的视频片段 */ async regenerateVideoSegment( shotPrompt: LensType[], - dialogueContent: ContentItem[], - roleReplaceParams: { oldId: string; newId: string }[], - sceneReplaceParams: { oldId: string; newId: string }[] + shotId?: string, + roleReplaceParams?: { oldId: string; newId: string }[], + sceneReplaceParams?: { oldId: string; newId: string }[] ): Promise { - const videoSegmentId = this.videoSegmentItem.entity.id; + try { + this.loading = true; - if (!videoSegmentId) { - throw new Error('视频片段ID不存在,无法重新生成视频片段'); - } + // 创建新的中断控制器 + this.abortController = new AbortController(); - // 调用重新生成视频片段接口 - const response = await regenerateShot({ - shotId: videoSegmentId, - shotPrompt: shotPrompt, - dialogueContent: dialogueContent, - roleReplaceParams: roleReplaceParams, - sceneReplaceParams: sceneReplaceParams, - }); + const response = await regenerateShot({ + shotId, + shotPrompt, + roleReplaceParams, + sceneReplaceParams, + }); - if (response.successful) { - const videoSegmentEntity = response.data; - this.videoSegmentItem.setEntity(videoSegmentEntity); - // 检查状态是否需要更新为视频状态 - this.checkAndUpdateVideoStatus(videoSegmentEntity); - return videoSegmentEntity; - } else { - throw new Error(`重新生成视频片段失败: ${response.message}`); - } - } - - /** - * 修改视频片段对话内容 - * @description 更新视频片段的对话内容,ContentItem数量和ID顺序不能变,只能修改content字段 - * @param newContent 新的对话内容数组 - * @returns Promise 修改后的视频片段实体 - * @throws {Error} 当API调用失败时抛出错误 - */ - async updateVideoSegmentContent(newContent: Array<{ roleId: string; content: string }>): Promise { - const videoSegmentId = this.videoSegmentItem.entity.id; - - if (!videoSegmentId) { - throw new Error('视频片段ID不存在,无法修改对话内容'); - } - - // 验证ContentItem数量和ID顺序 - const currentContent = this.videoSegmentItem.entity.content; - if (newContent.length !== currentContent.length) { - throw new Error('ContentItem数量不能改变'); - } - - // 验证角色ID顺序 - for (let i = 0; i < newContent.length; i++) { - if (newContent[i].roleId !== currentContent[i].roleId) { - throw new Error('ContentItem的roleId顺序不能改变'); + if (!response.successful) { + throw new Error(response.message || "重新生成视频片段失败"); } - } - const response = await updateShotContent({ - shotId: videoSegmentId, - content: newContent, - }); - - if (response.successful) { - const videoSegmentEntity = response.data; - this.videoSegmentItem.setEntity(videoSegmentEntity); - // 检查状态是否需要更新为视频状态 - this.checkAndUpdateVideoStatus(videoSegmentEntity); - return videoSegmentEntity; - } else { - throw new Error(`修改视频片段对话内容失败: ${response.message}`); + return response.data; + } catch (error) { + if (this.abortController?.signal.aborted) { + console.log("视频片段重新生成被中断"); + throw new Error("操作被中断"); + } + console.error("重新生成视频片段失败:", error); + throw error; + } finally { + this.loading = false; + this.abortController = null; } } /** - * 检查并更新视频状态 - * @description 当视频片段状态变为视频加载中或完成时,调用updateToVideoStatus - * @param videoSegmentEntity 视频片段实体 + * @description 通过AI优化镜头数据(包含对话内容) + * @param shotId 视频片段ID + * @param userRequirement 用户优化需求 + * @param lensData 镜头数据数组 + * @returns Promise 优化后的镜头数据 */ - private checkAndUpdateVideoStatus(videoSegmentEntity: VideoSegmentEntity): void { - // 当状态为视频加载中或完成时,更新为视频状态 - if (videoSegmentEntity.status === 1 || videoSegmentEntity.status === 2) { // videoLoading 或 finished - this.videoSegmentItem.updateToVideoStatus(); + async optimizeVideoContent( + shotId: string, + userRequirement: string, + lensData: LensType[] + ): Promise { + try { + this.loading = true; + + // 调用AI优化接口 + const response = await optimizeShotContent({ + shotId, + userRequirement, + lensData, + }); + + if (!response.successful) { + throw new Error(response.message || "AI优化视频内容失败"); + } + + return response.data; + } catch (error) { + console.error("AI优化视频内容失败:", error); + throw error; + } finally { + this.loading = false; } } + + /** + * @description 更新视频片段的对话内容 + * @param shotId 视频片段ID + * @param content 新的对话内容 + * @returns Promise 更新后的视频片段 + */ + async updateVideoContent( + shotId: string, + content: Array<{ + roleId: string; + content: string; + }> + ): Promise { + try { + this.loading = true; + + const response = await updateShotContent({ + shotId, + content, + }); + + if (!response.successful) { + throw new Error(response.message || "更新视频内容失败"); + } + + return response.data; + } catch (error) { + console.error("更新视频内容失败:", error); + throw error; + } finally { + this.loading = false; + } + } + + /** + * @description 中断当前操作 + */ + abortOperation(): void { + if (this.abortController) { + this.abortController.abort(); + this.loading = false; + } + } + + /** + * @description 获取加载状态 + * @returns boolean 是否正在加载 + */ + isLoading(): boolean { + return this.loading; + } }