This commit is contained in:
海龙 2025-08-12 15:02:42 +08:00
commit 4cf967de35
10 changed files with 303 additions and 115 deletions

View File

@ -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;
}
}

View File

@ -93,6 +93,8 @@ export interface TagValueObject {
loadingProgress: number;
/** 禁止编辑 */
disableEdit: boolean;
/** 颜色 */
color?: string;
}

View File

@ -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' && (
<div className="absolute right-12 bottom-16 z-[9999] flex gap-4">
(currentStep !== '6' && currentStep !== '0') && (
<div className="absolute right-12 bottom-16 z-[49] flex gap-4">
<GlassIconButton
icon={isPauseWorkFlow ? Play : Pause}
size='lg'
@ -273,6 +274,8 @@ export default function WorkFlow() {
setIsPauseWorkFlow={setIsPauseWorkFlow}
setAnyAttribute={setAnyAttribute}
isPauseWorkFlow={isPauseWorkFlow}
applyScript={applyScript}
fallbackToStep={fallbackToStep}
/>
</ErrorBoundary>
</div>

View File

@ -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<any[]>([]);
const [roleData, setRoleData] = useState<any[]>([]);
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
}
}

View File

@ -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
};
}

View File

@ -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<any, CharacterEditorProps>(({
className,
}: CharacterEditorProps) {
description,
highlight,
onSmartPolish
}, ref) => {
const [isOptimizing, setIsOptimizing] = useState(false);
const [content, setContent] = useState<any[]>([]);
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 (
<div className={cn("space-y-2 border border-white/10 relative p-2 rounded-[0.5rem] pb-12", className)}>
{/* 自由输入区域 */}
<MainEditor content={mockContent} />
{
!isInit && <MainEditor content={content} onChangeContent={setContent} />
}
{/* 智能润色按钮 */}
<motion.button
@ -60,4 +95,4 @@ export default function CharacterEditor({
</motion.button>
</div>
);
}
});

View File

@ -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<HTMLInputElement>(null);
const [enableAnimation, setEnableAnimation] = useState(true);
const [showAddToLibrary, setShowAddToLibrary] = useState(true);
const {fetchRoleList,roleList,fetchUserRoleLibrary,userRoleLibrary} = useRoleServiceHook()
const characterEditorRef = useRef<any>(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 (
<div className="flex flex-col items-center justify-center min-h-[400px] text-white/50">
<div className="w-12 h-12 mb-4 animate-spin rounded-full border-b-2 border-blue-600" />
<p>Loading...</p>
</div>
);
}
// 如果没有角色数据,显示占位内容
if (!roles || roles.length === 0) {
if (roleData.length === 0) {
return (
<div className="flex flex-col items-center justify-center min-h-[400px] text-white/50">
<Users className="w-16 h-16 mb-4" />
<p>No character data</p>
<p>No role data</p>
</div>
);
}
@ -210,7 +237,7 @@ export function CharacterTabContent({
selectedIndex={selectRoleIndex}
onItemClick={(i: number) => handleChangeRole(i)}
>
{roleList.map((role, index) => (
{roleData.map((role, index) => (
<motion.div
key={`role-${index}`}
className={cn(
@ -248,8 +275,8 @@ export function CharacterTabContent({
{/* 角色预览图 */}
<div className="w-full h-full mx-auto rounded-lg relative group">
<ImageBlurTransition
src={currentRole.url}
alt={currentRole.name}
src={roleData[selectRoleIndex].imageUrl || ''}
alt={roleData[selectRoleIndex].name}
width='100%'
height='100%'
enableAnimation={enableAnimation}
@ -281,7 +308,11 @@ export function CharacterTabContent({
{/* 右列:角色信息 */}
<div className="space-y-4">
<CharacterEditor
ref={characterEditorRef}
className="min-h-[calc(100%-4rem)]"
description={roleData[selectRoleIndex].generateText || ''}
highlight={roleData[selectRoleIndex].tags || []}
onSmartPolish={handleSmartPolish}
/>
{/* 重新生成按钮、替换形象按钮 */}
<div className="grid grid-cols-2 gap-2">

View File

@ -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({
<div className="flex flex-col items-center gap-4 text-white py-4">
<div className="flex items-center gap-3">
<TriangleAlert className="w-6 h-6 text-yellow-400" />
<p className="text-lg font-medium"></p>
<p className="text-lg font-medium">The task will be regenerated and edited. Do you want to continue?</p>
</div>
<div className="flex gap-3 mt-2">
@ -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"
>
<Undo2 className="w-4 h-4" />
Continue
</button>
<button
@ -314,7 +323,7 @@ export function EditModal({
className="px-4 py-2 bg-gray-600 hover:bg-gray-700 rounded-md transition-colors duration-200 flex items-center gap-2"
>
<X className="w-4 h-4" />
Cancel
</button>
</div>
</div>

View File

@ -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<any[]>(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) {

View File

@ -236,7 +236,7 @@ export function ShotTabContent({
>
<div className="flex items-center gap-2">
<span className="text-sm whitespace-nowrap flex items-center gap-1">
<span className='text-white/50'>Segment {index + 1}</span>
<span>Segment {index + 1}</span>
{shot.status === 0 && (
<Loader2 className="w-4 h-4 animate-spin text-blue-500" />
)}