diff --git a/app/service/adapter/textToShot.ts b/app/service/adapter/textToShot.ts index 3c6d5ca..de4da67 100644 --- a/app/service/adapter/textToShot.ts +++ b/app/service/adapter/textToShot.ts @@ -1,4 +1,4 @@ -import { ContentItem, LensType, SimpleCharacter } from '../domain/valueObject'; +import { ContentItem, LensType, SimpleCharacter, TagValueObject } from '../domain/valueObject'; // 定义角色属性接口 interface CharacterAttributes { @@ -8,6 +8,12 @@ interface CharacterAttributes { avatar: string; } +// 定义高亮属性接口 +interface HighlightAttributes { + text: string; + color: string; +} + // 定义文本节点接口 interface TextNode { type: 'text'; @@ -20,8 +26,14 @@ interface CharacterTokenNode { attrs: CharacterAttributes; } +// 定义高亮节点接口 +interface HighlightNode { + type: 'highlightText'; + attrs: HighlightAttributes; +} + // 定义内容节点类型(文本或角色标记) -type ContentNode = TextNode | CharacterTokenNode; +type ContentNode = TextNode | CharacterTokenNode | HighlightNode; // 定义段落接口 interface Paragraph { @@ -100,6 +112,68 @@ export class TextToShotAdapter { return nodes; } + /** + * 解析高亮文本,识别tag并转换为节点数组 + * @param text 要解析的文本 + * @param tags 标签列表 + * @returns ContentNode[] 节点数组 + */ + public static parseHighlight(text: string, tags: TagValueObject[]): ContentNode[] { + const nodes: ContentNode[] = []; + let currentText = text; + // 按内容长度降序排序,避免短名称匹配到长名称的一部分 + const sortedTags = [...tags].sort((a, b) => String(b.content).length - String(a.content).length); + + while (currentText.length > 0) { + let matchFound = false; + + // 尝试匹配 + for (const tag of sortedTags) { + if (currentText.startsWith(String(tag.content))) { + // 如果当前文本以tag内容开头 + if (currentText.length > String(tag.content).length) { + // 添加标记节点 + nodes.push({ + type: 'highlightText', + attrs: { + text: String(tag.content), + color: tag?.color || 'yellow' + } + }); + // 移除已处理的tag内容 + currentText = currentText.slice(String(tag.content).length); + matchFound = true; + break; + } + } + } + + if (!matchFound) { + // 如果没有找到tag匹配,处理普通文本 + // 查找下一个可能的tag内容位置 + let nextTagIndex = currentText.length; + for (const tag of sortedTags) { + const index = currentText.indexOf(String(tag.content)); + if (index !== -1 && index < nextTagIndex) { + nextTagIndex = index; + } + } + + // 添加文本节点 + const textContent = currentText.slice(0, nextTagIndex); + if (textContent) { + nodes.push({ + type: 'text', + text: textContent + }); + } + // 移除已处理的文本 + currentText = currentText.slice(nextTagIndex); + } + } + + return nodes; + } private readonly ShotData: Shot; constructor(shotData: Shot) { this.ShotData = shotData; @@ -225,4 +299,29 @@ export class TextToShotAdapter { content ); } + + public static fromTextToRole(description: string, tags: TagValueObject[]): Paragraph[] { + const paragraph: Paragraph = { + type: 'paragraph', + content: [] + }; + const highlightNodes = TextToShotAdapter.parseHighlight(description, tags); + paragraph.content.push(...highlightNodes); + return [paragraph]; + } + public static fromRoleToText(paragraphs: Paragraph[]): string { + let text = ''; + paragraphs.forEach(paragraph => { + paragraph.content.forEach(node => { + if (node.type === 'highlightText') { + text += node.attrs.text; + } else if (node.type === 'text') { + text += node.text; + } else if (node.type === 'characterToken') { + text += node.attrs.name; + } + }); + }); + return text; + } } \ No newline at end of file diff --git a/app/service/domain/valueObject.ts b/app/service/domain/valueObject.ts index 0d6e30a..25b5be2 100644 --- a/app/service/domain/valueObject.ts +++ b/app/service/domain/valueObject.ts @@ -93,6 +93,8 @@ export interface TagValueObject { loadingProgress: number; /** 禁止编辑 */ disableEdit: boolean; + /** 颜色 */ + color?: string; } diff --git a/components/pages/work-flow.tsx b/components/pages/work-flow.tsx index 1f024ed..5966bd3 100644 --- a/components/pages/work-flow.tsx +++ b/components/pages/work-flow.tsx @@ -46,7 +46,8 @@ export default function WorkFlow() { mode, setIsPauseWorkFlow, setAnyAttribute, - applyScript + applyScript, + fallbackToStep } = useWorkflowData(); const { @@ -225,8 +226,8 @@ export default function WorkFlow() { {/* 暂停/播放按钮 */} { - currentStep !== '6' && ( -
+ (currentStep !== '6' && currentStep !== '0') && ( +
diff --git a/components/pages/work-flow/use-edit-data.tsx b/components/pages/work-flow/use-edit-data.tsx index 5b28a82..54495de 100644 --- a/components/pages/work-flow/use-edit-data.tsx +++ b/components/pages/work-flow/use-edit-data.tsx @@ -2,74 +2,21 @@ import { useEffect, useState } from "react"; import { useShotService } from "@/app/service/Interaction/ShotService"; import { useSearchParams } from 'next/navigation'; +import { useRoleServiceHook } from "@/app/service/Interaction/RoleService"; -const mockShotData = [ - { - id: '1', - name: 'Shot 1', - sketchUrl: 'https://example.com/sketch.png', - videoUrl: ['https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ1-0-20250725023719.mp4'], - status: 1, // 0:视频加载中 1:任务已完成 2:任务失败 - lens: [ - { - name: 'Shot 1', - script: '镜头聚焦在 President Alfred King 愤怒的脸上,他被压制住了。他发出一声绝望的吼叫,盖过了枪声。', - content: [{ - roleName: 'President Alfred King', - content: '我需要一个镜头,镜头聚焦在 愤怒的脸上,他被压制住了。他发出一声绝望的吼叫,盖过了枪声。' - }] - } - ] - }, { - id: '2', - name: 'Shot 2', - sketchUrl: 'https://example.com/sketch.png', - videoUrl: ['https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ3-0-20250725023725.mp4'], - status: 1, // 0:视频加载中 1:任务已完成 2:任务失败 - lens: [ - { - name: 'Shot 1', - script: '镜头聚焦在 Samuel Ryan 愤怒的脸上,他被压制住了。他发出一声绝望的吼叫,盖过了枪声。', - content: [{ - roleName: 'Samuel Ryan', - content: '我需要一个镜头,镜头聚焦在 Samuel Ryan 愤怒的脸上,他被压制住了。他发出一声绝望的吼叫,盖过了枪声。' - }] - } - ] - }, { - id: '3', - name: 'Shot 3', - sketchUrl: 'https://example.com/sketch.png', - videoUrl: [], - status: 0, // 0:视频加载中 1:任务已完成 2:任务失败 - lens: [ - { - name: 'Shot 1', - script: '镜头聚焦在 Samuel Ryan 愤怒的脸上,他被压制住了。他发出一声绝望的吼叫,盖过了枪声。', - content: [{ - roleName: 'Samuel Ryan', - content: '我需要一个镜头,镜头聚焦在 Samuel Ryan 愤怒的脸上,他被压制住了。他发出一声绝望的吼叫,盖过了枪声。' - }] - } - ] - }, { - id: '4', - name: 'Shot 4', - sketchUrl: 'https://example.com/sketch.png', - videoUrl: [], - status: 2, // 0:视频加载中 1:任务已完成 2:任务失败 - lens: [ - { - name: 'Shot 1', - script: '镜头聚焦在 Samuel Ryan 愤怒的脸上,他被压制住了。他发出一声绝望的吼叫,盖过了枪声。', - content: [{ - roleName: 'Samuel Ryan', - content: '我需要一个镜头,镜头聚焦在 Samuel Ryan 愤怒的脸上,他被压制住了。他发出一声绝望的吼叫,盖过了枪声。' - }] - } - ] - } -] +const mockRoleData = [{ + id: '1', + name: 'KAPI', + imageUrl: 'https://c.huiying.video/images/420bfb4f-b5d4-475c-a2fb-5e40af770b29.jpg', + generateText: 'A 3 to 5-year-old boy with a light to medium olive skin tone, full cheeks, and warm brown eyes. He has short, straight, dark brown hair, neatly styled with a part on his left side. His facial structure includes a small, slightly upturned nose. His lips are typically held in a slight, gentle, closed-mouth smile, which can part to show his small, white teeth.', + tags: [ + { id: '1', content: 'boy', color: 'red' }, + { id: '2', content: '3 to 5-year-old', color: 'yellow' }, + { id: '3', content: 'light to medium olive skin tone', color: 'green' }, + { id: '4', content: 'full cheeks', color: 'blue' }, + { id: '5', content: 'warm brown eyes', color: 'purple' }, + ] +}] export const useEditData = (tabType: string) => { const searchParams = useSearchParams(); @@ -77,6 +24,8 @@ export const useEditData = (tabType: string) => { const [loading, setLoading] = useState(true); const [shotData, setShotData] = useState([]); + const [roleData, setRoleData] = useState([]); + const { videoSegments, getVideoSegmentList, @@ -85,13 +34,34 @@ export const useEditData = (tabType: string) => { filterRole } = useShotService(); + const { + roleList, + selectedRole, + userRoleLibrary, + fetchRoleList, + selectRole, + fetchUserRoleLibrary, + optimizeRoleText, + updateRoleText, + regenerateRole + } = useRoleServiceHook(); + useEffect(() => { if (tabType === 'shot') { getVideoSegmentList(projectId).then(() => { setLoading(false); }).catch((err) => { console.log('useEditData-----err', err); - setShotData(mockShotData); + setShotData([]); + setLoading(false); + }); + } else if (tabType === 'role') { + fetchUserRoleLibrary(); + fetchRoleList(projectId).then(() => { + setLoading(false); + }).catch((err) => { + console.log('useEditData-----err', err); + setRoleData([]); setLoading(false); }); } @@ -102,11 +72,25 @@ export const useEditData = (tabType: string) => { setShotData(videoSegments); }, [videoSegments]); + useEffect(() => { + // setRoleData(roleList); + setRoleData(mockRoleData); + }, [roleList]); + return { loading, + // shot shotData, setSelectedSegment, regenerateVideoSegment, - filterRole + filterRole, + // role + roleData, + selectRole, + selectedRole, + userRoleLibrary, + optimizeRoleText, + updateRoleText, + regenerateRole } } \ No newline at end of file diff --git a/components/pages/work-flow/use-workflow-data.tsx b/components/pages/work-flow/use-workflow-data.tsx index 26e99d4..2a77e2b 100644 --- a/components/pages/work-flow/use-workflow-data.tsx +++ b/components/pages/work-flow/use-workflow-data.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react'; import { useSearchParams } from 'next/navigation'; -import { detailScriptEpisodeNew, getScriptTitle, getRunningStreamData } from '@/api/video_flow'; +import { detailScriptEpisodeNew, getScriptTitle, getRunningStreamData, pauseMovieProjectPlan, resumeMovieProjectPlan } from '@/api/video_flow'; import { useAppDispatch, useAppSelector } from '@/lib/store/hooks'; import { setSketchCount, setVideoCount } from '@/lib/store/workflowSlice'; import { useScriptService } from "@/app/service/Interaction/ScriptService"; @@ -96,7 +96,7 @@ export function useWorkflowData() { console.log('开始初始化剧本', originalText); originalText && initializeFromProject(episodeId, originalText).then(() => { console.log('应用剧本'); - // 默认模式下 应用剧本 + // 自动模式下 应用剧本;手动模式 需要点击 下一步 触发 mode.includes('auto') && applyScript(); }); }, [originalText]); @@ -110,7 +110,11 @@ export function useWorkflowData() { }, [scriptBlocksMemo]); // 监听继续 请求更新数据 useEffect(() => { - + if (isPauseWorkFlow) { + pauseMovieProjectPlan({ project_id: episodeId }); + } else { + resumeMovieProjectPlan({ project_id: episodeId }); + } }, [isPauseWorkFlow]); // 自动开始播放一轮 @@ -568,6 +572,12 @@ export function useWorkflowData() { } }; + // 回退到 指定状态 重新获取数据 + const fallbackToStep = (step: string) => { + setCurrentStep(step); + setNeedStreamData(true); + } + // 重试加载数据 const retryLoadData = () => { setDataLoadError(null); @@ -617,6 +627,7 @@ export function useWorkflowData() { mode, setIsPauseWorkFlow, setAnyAttribute, - applyScript + applyScript, + fallbackToStep }; } diff --git a/components/ui/character-editor.tsx b/components/ui/character-editor.tsx index b0af4c8..1834e9d 100644 --- a/components/ui/character-editor.tsx +++ b/components/ui/character-editor.tsx @@ -1,11 +1,16 @@ -import { useState, useRef } from "react"; +import React, { useState, useRef, useEffect, forwardRef } from "react"; import { motion } from "framer-motion"; import { Sparkles, X, Plus, RefreshCw } from 'lucide-react'; import MainEditor from "./main-editor/MainEditor"; import { cn } from "@/public/lib/utils"; +import { TextToShotAdapter } from "@/app/service/adapter/textToShot"; +import { TagValueObject } from "@/app/service/domain/valueObject"; interface CharacterEditorProps { className?: string; + description: string; + highlight: TagValueObject[]; + onSmartPolish: (text: string) => void; } const mockContent = [ @@ -30,20 +35,50 @@ const mockContent = [ }, ]; - -export default function CharacterEditor({ +export const CharacterEditor = forwardRef(({ className, -}: CharacterEditorProps) { + description, + highlight, + onSmartPolish +}, ref) => { const [isOptimizing, setIsOptimizing] = useState(false); + const [content, setContent] = useState([]); + const [isInit, setIsInit] = useState(true); const handleSmartPolish = async () => { - + setIsOptimizing(true); + console.log('-==========handleSmartPolish===========-', content); + const text = TextToShotAdapter.fromRoleToText(content); + console.log('-==========getText===========-', text); + onSmartPolish(text); }; + useEffect(() => { + setIsInit(true); + console.log('-==========description===========-', description); + console.log('-==========highlight===========-', highlight); + const paragraphs = TextToShotAdapter.fromTextToRole(description, highlight); + console.log('-==========paragraphs===========-', paragraphs); + setContent(paragraphs); + setTimeout(() => { + setIsInit(false); + setIsOptimizing(false); + }, 100); + }, [description, highlight]); + + // 暴露方法给父组件 + React.useImperativeHandle(ref, () => ({ + getRoleText: () => { + return TextToShotAdapter.fromRoleToText(content); + } + })); + return (
{/* 自由输入区域 */} - + { + !isInit && + } {/* 智能润色按钮 */}
); -} +}); diff --git a/components/ui/character-tab-content.tsx b/components/ui/character-tab-content.tsx index 3c0e8bb..bfc05c0 100644 --- a/components/ui/character-tab-content.tsx +++ b/components/ui/character-tab-content.tsx @@ -2,13 +2,13 @@ import React, { useState, useRef, useEffect } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { ImageUp, Library, Play, Pause, RefreshCw, Wand2, Users, Check, ReplaceAll, X, TriangleAlert } from 'lucide-react'; import { cn } from '@/public/lib/utils'; -import CharacterEditor from './character-editor'; +import { CharacterEditor } from './character-editor'; import ImageBlurTransition from './ImageBlurTransition'; import FloatingGlassPanel from './FloatingGlassPanel'; import { ReplaceCharacterPanel, mockShots, mockCharacter } from './replace-character-panel'; import { CharacterLibrarySelector } from './character-library-selector'; import HorizontalScroller from './HorizontalScroller'; -import { useRoleServiceHook } from '@/app/service/Interaction/RoleService'; +import { useEditData } from '@/components/pages/work-flow/use-edit-data'; interface Appearance { hairStyle: string; @@ -63,8 +63,6 @@ export function CharacterTabContent({ onSketchSelect, roles = [mockRole] }: CharacterTabContentProps) { - const [localRole, setLocalRole] = useState(mockRole); - const [currentRole, setCurrentRole] = useState(roles[currentRoleIndex]); const [isReplacePanelOpen, setIsReplacePanelOpen] = useState(false); const [replacePanelKey, setReplacePanelKey] = useState(0); const [ignoreReplace, setIgnoreReplace] = useState(false); @@ -74,14 +72,33 @@ export function CharacterTabContent({ const fileInputRef = useRef(null); const [enableAnimation, setEnableAnimation] = useState(true); const [showAddToLibrary, setShowAddToLibrary] = useState(true); - const {fetchRoleList,roleList,fetchUserRoleLibrary,userRoleLibrary} = useRoleServiceHook() + const characterEditorRef = useRef(null); + + const { + loading, + roleData, + selectRole, + selectedRole, + userRoleLibrary, + optimizeRoleText, + updateRoleText, + regenerateRole + } = useEditData('role'); + useEffect(() => { - // 从url 获取 episodeId 作为projctId - const projectId = new URLSearchParams(window.location.search).get('episodeId'); - if (projectId) { - fetchRoleList(projectId); + console.log('-==========roleData===========-', roleData); + if (roleData.length > 0) { + selectRole(roleData[selectRoleIndex].id); } - }, [fetchRoleList]); + }, [selectRoleIndex, roleData]); + + const handleSmartPolish = (text: string) => { + // 首先更新 + updateRoleText(text); + // 然后调用优化角色文本 + optimizeRoleText(text); + }; + const handleConfirmGotoReplace = () => { setIsRemindReplacePanelOpen(false); setIsReplacePanelOpen(true); @@ -94,10 +111,7 @@ export function CharacterTabContent({ const handleReplaceCharacter = (url: string) => { setEnableAnimation(true); - setCurrentRole({ - ...currentRole, - url: url - }); + // 替换角色 setIsReplacePanelOpen(true); }; @@ -115,7 +129,7 @@ export function CharacterTabContent({ }; const handleChangeRole = (index: number) => { - if (currentRole.url !== roles[selectRoleIndex].url && !ignoreReplace) { + if (selectedRole?.imageUrl !== roleData[selectRoleIndex].imageUrl && !ignoreReplace) { // 提示 角色已修改,弹出替换角色面板 setIsRemindReplacePanelOpen(true); return; @@ -125,7 +139,6 @@ export function CharacterTabContent({ setIgnoreReplace(false); setSelectRoleIndex(index); - setCurrentRole(roles[index]); }; // 从角色库中选择角色 @@ -146,12 +159,16 @@ export function CharacterTabContent({ const handleOpenReplaceLibrary = () => { setIsReplaceLibraryOpen(true); setShowAddToLibrary(true); - fetchUserRoleLibrary(); }; const handleRegenerate = () => { console.log('Regenerate'); - setShowAddToLibrary(true); + const text = characterEditorRef.current.getRoleText(); + console.log('-==========text===========-', text); + // 重生前 更新 当前项 generateText + updateRoleText(text); + // 然后调用重新生成角色 + regenerateRole(); }; const handleUploadClick = () => { @@ -177,12 +194,22 @@ export function CharacterTabContent({ event.target.value = ''; }; + // 如果loading 显示loading状态 + if (loading) { + return ( +
+
+

Loading...

+
+ ); + } + // 如果没有角色数据,显示占位内容 - if (!roles || roles.length === 0) { + if (roleData.length === 0) { return (
-

No character data

+

No role data

); } @@ -210,7 +237,7 @@ export function CharacterTabContent({ selectedIndex={selectRoleIndex} onItemClick={(i: number) => handleChangeRole(i)} > - {roleList.map((role, index) => ( + {roleData.map((role, index) => ( {/* 重新生成按钮、替换形象按钮 */}
diff --git a/components/ui/edit-modal.tsx b/components/ui/edit-modal.tsx index 36b7baa..3c3024b 100644 --- a/components/ui/edit-modal.tsx +++ b/components/ui/edit-modal.tsx @@ -29,6 +29,7 @@ interface EditModalProps { isPauseWorkFlow: boolean; scriptData: any[] | null; applyScript: any; + fallbackToStep: any; } const tabs = [ @@ -57,7 +58,8 @@ export function EditModal({ setAnyAttribute, isPauseWorkFlow, scriptData, - applyScript + applyScript, + fallbackToStep }: EditModalProps) { const [activeTab, setActiveTab] = useState(activeEditTab); const [currentIndex, setCurrentIndex] = useState(currentSketchIndex); @@ -104,6 +106,13 @@ export function EditModal({ const handleConfirmGotoFallback = () => { console.log('handleConfirmGotoFallback'); + if (activeTab === '0') { + fallbackToStep('0'); + // 应用剧本 + applyScript(); + } else { + fallbackToStep('1'); + } } const handleCloseRemindFallbackPanel = () => { setIsRemindFallbackOpen(false); @@ -295,7 +304,7 @@ export function EditModal({
-

将重新生成视频并剪辑,是否需要继续?

+

The task will be regenerated and edited. Do you want to continue?

@@ -305,7 +314,7 @@ export function EditModal({ className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-md transition-colors duration-200 flex items-center gap-2" > - 继续 + Continue
diff --git a/components/ui/main-editor/MainEditor.tsx b/components/ui/main-editor/MainEditor.tsx index 35a96a2..605d1ac 100644 --- a/components/ui/main-editor/MainEditor.tsx +++ b/components/ui/main-editor/MainEditor.tsx @@ -1,19 +1,27 @@ import React, { useState, useCallback, useEffect } from 'react'; +import { flushSync } from 'react-dom'; import { EditorContent, useEditor } from '@tiptap/react'; import StarterKit from '@tiptap/starter-kit'; import { HighlightTextExtension } from './HighlightText'; interface MainEditorProps { content: any[]; + onChangeContent?: (content: any[]) => void; } -export default function MainEditor({ content }: MainEditorProps) { +export default function MainEditor({ content, onChangeContent }: MainEditorProps) { + const [renderContent, setRenderContent] = useState(content); + + useEffect(() => { + onChangeContent?.(renderContent);; + }, [renderContent]); + const editor = useEditor({ extensions: [ StarterKit, HighlightTextExtension, ], - content: { type: 'doc', content: content }, + content: { type: 'doc', content: renderContent }, editorProps: { attributes: { class: 'prose prose-invert max-w-none focus:outline-none' @@ -23,6 +31,12 @@ export default function MainEditor({ content }: MainEditorProps) { onCreate: ({ editor }) => { editor.setOptions({ editable: true }) }, + onUpdate: ({ editor }) => { + const json = editor.getJSON(); + flushSync(() => { + setRenderContent(json.content); + }); + }, }); if (!editor) { diff --git a/components/ui/shot-tab-content.tsx b/components/ui/shot-tab-content.tsx index a64a2f4..97742f4 100644 --- a/components/ui/shot-tab-content.tsx +++ b/components/ui/shot-tab-content.tsx @@ -236,7 +236,7 @@ export function ShotTabContent({ >
- Segment {index + 1} + Segment {index + 1} {shot.status === 0 && ( )}