This commit is contained in:
海龙 2025-08-05 20:13:19 +08:00
commit 12932a9f67
8 changed files with 342 additions and 77 deletions

View File

@ -1,10 +1,8 @@
"use client";
import { useState, useEffect, useRef, useCallback } from 'react';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { ArrowLeft, ChevronDown, ChevronUp, Video, ListOrdered, Play, Loader2, Pause, MoreHorizontal, Edit2, Check, X, RefreshCw, Lightbulb, Package, Crown, ArrowUp, Search, Filter, Grid, Grid3X3, Calendar, Clock, Eye, Heart, Share2 } from 'lucide-react';
import { ArrowLeft, ChevronDown, ChevronUp, Video, ListOrdered, Play, Loader2, Pause, MoreHorizontal, Edit2, Check, X, RefreshCw, Lightbulb, Package, Crown, ArrowUp, Search, Filter, Grid, Grid3X3, Calendar, Clock, Eye, Heart, Share2, Globe } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger, SheetClose } from "@/components/ui/sheet";
import { Input } from "@/components/ui/input";
import './style/create-to-video2.css';
import { Dropdown, Menu } from 'antd';
@ -14,7 +12,6 @@ import { ModeEnum, ResolutionEnum } from "@/app/model/enums";
import { createScriptEpisodeNew, getScriptEpisodeListNew, CreateScriptEpisodeRequest } from "@/api/script_episode";
import { getUploadToken, uploadToQiniu } from "@/api/common";
import { EmptyStateAnimation } from '@/components/common/EmptyStateAnimation2';
import { ScriptEditDialog } from '@/components/script-edit-dialog';
const ideaText = 'a cute capybara with an orange on its head, staring into the distance and walking forward';
@ -32,6 +29,7 @@ export function CreateToVideo2() {
const [isFocus, setIsFocus] = useState(false);
const [selectedMode, setSelectedMode] = useState<ModeEnum>(ModeEnum.AUTOMATIC);
const [selectedResolution, setSelectedResolution] = useState<ResolutionEnum>(ResolutionEnum.HD_720P);
const [selectedLanguage, setSelectedLanguage] = useState<string>('en');
const [script, setInputText] = useState('');
const editorRef = useRef<HTMLDivElement>(null);
const [runTour, setRunTour] = useState(true);
@ -45,15 +43,12 @@ export function CreateToVideo2() {
const [limit, setLimit] = useState(12);
const [isLoading, setIsLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [filterStatus, setFilterStatus] = useState<string>('all');
const [sortBy, setSortBy] = useState<string>('created_at');
const [isLoadingMore, setIsLoadingMore] = useState(false);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [isSmartAssistantExpanded, setIsSmartAssistantExpanded] = useState(false);
const [userId, setUserId] = useState<number>(0);
const [isComposing, setIsComposing] = useState(false);
const [isScriptEditDialogOpen, setIsScriptEditDialogOpen] = useState(false);
const [loadingIdea, setLoadingIdea] = useState(false);
// 在客户端挂载后读取localStorage
useEffect(() => {
if (typeof window !== 'undefined') {
@ -135,8 +130,6 @@ export function CreateToVideo2() {
}
const handleCreateVideo = async () => {
setIsScriptEditDialogOpen(true);
return;
setIsCreating(true);
// 创建剧集数据
let episodeData: any = {
@ -242,6 +235,53 @@ export function CreateToVideo2() {
},
];
// 语言选项配置
const languageItems: MenuProps['items'] = [
{
type: 'group',
label: (
<div className="text-white/50 text-xs px-2 pb-2">Language</div>
),
children: [
{
key: 'en',
label: (
<div className="flex items-center justify-between p-1">
<span className="text-base">English</span>
</div>
),
},
{
key: 'zh',
label: (
<div className="flex items-center justify-between p-1">
<span className="text-base">Chinese</span>
<Crown className="w-4 h-4 text-yellow-500" />
</div>
),
},
{
key: 'ja',
label: (
<div className="flex items-center justify-between p-1">
<span className="text-base">Japanese</span>
<Crown className="w-4 h-4 text-yellow-500" />
</div>
),
},
{
key: 'ko',
label: (
<div className="flex items-center justify-between p-1">
<span className="text-base">Korean</span>
<Crown className="w-4 h-4 text-yellow-500" />
</div>
),
},
],
},
];
// 处理模式选择
const handleModeSelect: MenuProps['onClick'] = ({ key }) => {
setSelectedMode(key as ModeEnum);
@ -252,6 +292,21 @@ export function CreateToVideo2() {
setSelectedResolution(key as ResolutionEnum);
};
// 处理语言选择
const handleLanguageSelect: MenuProps['onClick'] = ({ key }) => {
setSelectedLanguage(key as string);
};
// 处理获取想法
const handleGetIdea = () => {
if (loadingIdea) return;
setLoadingIdea(true);
setTimeout(() => {
setInputText('idea');
setLoadingIdea(false);
}, 3000);
};
const handleStartCreating = () => {
setActiveTab('script');
setInputText(ideaText);
@ -402,6 +457,8 @@ export function CreateToVideo2() {
className="w-full h-full object-cover"
muted
loop
preload="none"
poster={`${episode.final_video_url}?vframe/jpg/offset/1`}
onMouseEnter={(e) => (e.target as HTMLVideoElement).play()}
onMouseLeave={(e) => (e.target as HTMLVideoElement).pause()}
/>
@ -618,9 +675,15 @@ export function CreateToVideo2() {
<span>Describe the content you want to action. Get an </span>
<b
className='idea-link inline-flex items-center gap-0.5 text-white/[0.50] font-normal cursor-pointer pointer-events-auto underline'
onClick={() => setInputText(ideaText)}
onClick={() => handleGetIdea()}
>
{loadingIdea ? (
<Loader2 className='w-4 h-4 animate-spin' />
) : (
<>
<Lightbulb className='w-4 h-4' />idea
</>
)}
</b>
</div>
</div>
@ -669,6 +732,28 @@ export function CreateToVideo2() {
<Crown className={`w-4 h-4 text-yellow-500 ${selectedResolution === ResolutionEnum.HD_720P ? 'hidden' : ''}`} />
</div>
</Dropdown>
{/* 语言 */}
<Dropdown
menu={{
items: languageItems,
onClick: handleLanguageSelect,
selectedKeys: [selectedLanguage.toString()],
}}
trigger={['click']}
overlayClassName="mode-dropdown"
placement="bottomLeft"
>
<div className='tool-operation-button ant-dropdown-trigger'>
<Globe className='w-4 h-4' />
<span className='text-nowrap opacity-70'>
{selectedLanguage === 'en' ? 'English' :
selectedLanguage === 'zh' ? 'Chinese' :
selectedLanguage === 'ja' ? 'Japanese' : 'Korean'}
</span>
<Crown className={`w-4 h-4 text-yellow-500 ${selectedLanguage === 'en' ? 'hidden' : ''}`} />
</div>
</Dropdown>
</div>
</div>
</div>
@ -698,13 +783,6 @@ export function CreateToVideo2() {
<EmptyStateAnimation className='' />
</div>
)}
{isScriptEditDialogOpen && (
<ScriptEditDialog
isOpen={isScriptEditDialogOpen}
onClose={() => setIsScriptEditDialogOpen(false)}
/>
)}
</>
);
}

View File

@ -10,7 +10,7 @@ import { MediaViewer } from "./work-flow/media-viewer";
import { ThumbnailGrid } from "./work-flow/thumbnail-grid";
import { useWorkflowData } from "./work-flow/use-workflow-data";
import { usePlaybackControls } from "./work-flow/use-playback-controls";
import { AlertCircle, RefreshCw, Pause, Play } from "lucide-react";
import { AlertCircle, RefreshCw, Pause, Play, ChevronLast } from "lucide-react";
import { motion } from "framer-motion";
import { GlassIconButton } from '@/components/ui/glass-icon-button';
@ -22,6 +22,7 @@ export default function WorkFlow() {
// 使用自定义 hooks 管理状态
const {
taskObject,
scriptData,
taskSketch,
taskScenes,
taskShotSketch,
@ -41,6 +42,7 @@ export default function WorkFlow() {
setCurrentSketchIndex,
retryLoadData,
isPauseWorkFlow,
mode,
setIsPauseWorkFlow,
} = useWorkflowData();
@ -170,6 +172,7 @@ export default function WorkFlow() {
<div className="heroVideo-FIzuK1" style={{ aspectRatio: "16 / 9" }}>
<ErrorBoundary>
<MediaViewer
scriptData={scriptData}
currentStep={currentStep}
currentSketchIndex={currentSketchIndex}
taskSketch={taskSketch}
@ -212,14 +215,26 @@ export default function WorkFlow() {
</div>
{/* 暂停/播放按钮 */}
<div className="absolute right-12 bottom-12 z-[9999]">
{
currentStep !== '6' && (
<div className="absolute right-12 bottom-16 z-[9999] flex gap-4">
<GlassIconButton
icon={isPauseWorkFlow ? Play : Pause}
size='lg'
tooltip={isPauseWorkFlow ? "Play" : "Pause"}
onClick={() => setIsPauseWorkFlow(!isPauseWorkFlow)}
/>
{ mode !== 'auto' && (
<GlassIconButton
icon={ChevronLast}
size='lg'
tooltip="Next"
/>
)}
</div>
)
}
{/* AI 建议栏 */}
<ErrorBoundary>

View File

@ -7,8 +7,10 @@ import { ProgressiveReveal, presets } from '@/components/ui/progressive-reveal';
import { GlassIconButton } from '@/components/ui/glass-icon-button';
import { ScriptRenderer } from '@/components/script-renderer/ScriptRenderer';
import { mockScriptData } from '@/components/script-renderer/mock';
import { Skeleton } from '@/components/ui/skeleton';
interface MediaViewerProps {
scriptData: any;
currentStep: string;
currentSketchIndex: number;
taskSketch: any[];
@ -26,6 +28,7 @@ interface MediaViewerProps {
}
export function MediaViewer({
scriptData,
currentStep,
currentSketchIndex,
taskSketch,
@ -801,7 +804,22 @@ export function MediaViewer({
const renderScriptContent = () => {
return (
<div className="relative w-full h-full bg-white/10 rounded-lg overflow-hidden p-2">
{
scriptData ? (
<ScriptRenderer data={mockScriptData} />
) : (
<div className="flex gap-2 w-full h-full">
<div className="w-[70%] h-full rounded-lg gap-2 flex flex-col">
<Skeleton className="w-full h-[33%] rounded-lg" />
<Skeleton className="w-full h-[33%] rounded-lg" />
<Skeleton className="w-full h-[33%] rounded-lg" />
</div>
<div className="w-[30%] h-full rounded-lg">
<Skeleton className="w-full h-full rounded-lg" />
</div>
</div>
)
}
</div>
);
};

View File

@ -57,6 +57,7 @@ export function useWorkflowData() {
// 更新 taskObject 的类型
const [taskObject, setTaskObject] = useState<TaskObject | null>(null);
const [scriptData, setScriptData] = useState<any>(null);
const [taskSketch, setTaskSketch] = useState<any[]>([]);
const [taskScenes, setTaskScenes] = useState<any[]>([]);
const [taskShotSketch, setTaskShotSketch] = useState<any[]>([]);
@ -74,6 +75,7 @@ export function useWorkflowData() {
const [dataLoadError, setDataLoadError] = useState<string | null>(null);
const [needStreamData, setNeedStreamData] = useState(false);
const [isPauseWorkFlow, setIsPauseWorkFlow] = useState(false);
const [mode, setMode] = useState<'auto' | 'manual'>('manual');
const dispatch = useAppDispatch();
const { sketchCount, videoCount } = useAppSelector((state) => state.workflow);
@ -554,6 +556,7 @@ export function useWorkflowData() {
return {
taskObject,
scriptData,
taskSketch,
taskScenes,
taskShotSketch,
@ -574,6 +577,7 @@ export function useWorkflowData() {
retryLoadData,
handleManualPlay,
isPauseWorkFlow,
mode,
setIsPauseWorkFlow,
};
}

View File

@ -1,10 +1,11 @@
import React, { useRef, useState } from 'react';
import React, { useRef, useState, useMemo, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { SquarePen, Lightbulb, Navigation, Globe, Copy, SendHorizontal } from 'lucide-react';
import { ScriptData, ScriptBlock, ScriptContent } from './types';
import { SquarePen, Lightbulb, Navigation, Globe, Copy, SendHorizontal, X, Plus } from 'lucide-react';
import { ScriptData, ScriptBlock, ScriptContent, ThemeTagBgColor, ThemeType } from './types';
import ContentEditable, { ContentEditableEvent } from 'react-contenteditable';
import { toast } from 'sonner';
import { SelectDropdown } from '@/components/ui/select-dropdown';
import { TypewriterText } from '@/components/workflow/work-office/common/TypewriterText';
interface ScriptRendererProps {
data: ScriptData;
@ -16,6 +17,12 @@ export const ScriptRenderer: React.FC<ScriptRendererProps> = ({ data }) => {
const contentRefs = useRef<{ [key: string]: HTMLDivElement | null }>({});
const [editBlockId, setEditBlockId] = useState<string | null>(null);
const contentEditableRef = useRef<HTMLElement>(null);
const [addThemeTag, setAddThemeTag] = useState<string | null>(null);
const [isInit, setIsInit] = useState(true);
useEffect(() => {
setEditBlockId(null);
}, [activeBlockId]);
const scrollToBlock = (blockId: string) => {
const element = contentRefs.current[blockId];
@ -25,6 +32,14 @@ export const ScriptRenderer: React.FC<ScriptRendererProps> = ({ data }) => {
}
};
// 使用 useMemo 缓存标签颜色映射
const randomThemeTagBgColor = useMemo(() => {
return Object.values(ThemeTagBgColor).reduce((acc: Record<string, string>, color: string) => {
acc[color] = color;
return acc;
}, {});
}, []);
// 用于渲染展示的 JSX
const renderContent = (content: ScriptContent) => {
switch (content.type) {
@ -35,7 +50,15 @@ export const ScriptRenderer: React.FC<ScriptRendererProps> = ({ data }) => {
case 'italic':
return <em className="italic">{content.text}</em>;
default:
return <p className="mb-2">{content.text}</p>;
return <p className="mb-2">
{
isInit ? (
<TypewriterText text={content.text || ''} stableId={content.type} />
) : (
<span>{content.text}</span>
)
}
</p>;
}
};
@ -65,6 +88,11 @@ export const ScriptRenderer: React.FC<ScriptRendererProps> = ({ data }) => {
console.log(e.target.value);
};
const handleBlockTextBlur = (block: ScriptBlock) => (e: ContentEditableEvent) => {
console.log(e.target.value);
setEditBlockId(null);
};
const renderEditBlock = (block: ScriptBlock) => {
let blockHtmlText = '';
block.content.forEach(item => {
@ -76,6 +104,8 @@ export const ScriptRenderer: React.FC<ScriptRendererProps> = ({ data }) => {
innerRef={contentEditableRef}
html={formatTextToHtml(blockHtmlText)}
onChange={handleBlockTextChange(block)}
onBlur={handleBlockTextBlur(block)}
autoFocus={true}
className="block w-full min-h-[120px] bg-white/5 backdrop-blur-md p-4 text-white/90
rounded-lg border-unset outline-none pb-12
whitespace-pre-wrap break-words"
@ -84,24 +114,38 @@ export const ScriptRenderer: React.FC<ScriptRendererProps> = ({ data }) => {
);
};
const renderBlock = (block: ScriptBlock) => {
const isHovered = hoveredBlockId === block.id;
const isActive = activeBlockId === block.id;
const isEditing = editBlockId === block.id;
const renderTypeBlock = (block: ScriptBlock, isHovered: boolean, isActive: boolean, isEditing: boolean) => {
switch (block.type) {
case 'theme':
return (
<motion.div
key={block.id}
className={`relative p-4 mb-1 rounded-lg shadow-md transition-colors duration-300
${isActive ? 'bg-blue-500/20' : ''} hover:bg-blue-500/10`}
ref={(el) => (contentRefs.current[block.id] = el)}
onMouseEnter={() => setHoveredBlockId(block.id)}
onMouseLeave={() => setHoveredBlockId(null)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
>
<h2 className="text-2xl font-semibold mb-1 text-blue-500">{block.title}</h2>
<div className="flex flex-wrap gap-2 mt-2">
{block.content.map((item, index) => (
<div key={index} className={`flex items-center gap-1 px-2 rounded-full ${Object.values(ThemeTagBgColor)[index]}`}>
<span className={`text-sm px-2 py-1 rounded-md`}>{item.text}</span>
<X className="w-4 h-4 cursor-pointer text-blue-500/80" onClick={() => console.log(item.text)} />
</div>
))}
{/* 新增主题标签 */}
<div className='flex items-center gap-1'>
<div className='w-[10rem]'>
<SelectDropdown
dropdownId="theme-type"
label=""
options={Object.values(ThemeType).map(type => ({ label: type, value: type }))}
value={addThemeTag}
placeholder="Select Theme Type"
onChange={() => console.log('主题类型')}
/>
</div>
<button className='p-2 rounded-full bg-white/5 backdrop-blur-md'>
<Plus className="w-4 h-4 cursor-pointer text-white-600" onClick={() => console.log('新增主题标签')} />
</button>
</div>
</div>
)
default:
return (
<>
<AnimatePresence>
{(isHovered || isActive) && (
<motion.div
@ -115,6 +159,7 @@ export const ScriptRenderer: React.FC<ScriptRendererProps> = ({ data }) => {
onClick={() => {
setEditBlockId(block.id);
setActiveBlockId(block.id);
setIsInit(false);
}}
/>
<Copy
@ -136,6 +181,32 @@ export const ScriptRenderer: React.FC<ScriptRendererProps> = ({ data }) => {
))
)}
</div>
</>
);
}
};
const renderBlock = (block: ScriptBlock) => {
const isHovered = hoveredBlockId === block.id;
const isActive = activeBlockId === block.id;
const isEditing = editBlockId === block.id;
return (
<motion.div
key={block.id}
className={`relative p-4 mb-1 rounded-lg shadow-md transition-colors duration-300
${isActive ? 'bg-slate-700/50' : ''} hover:bg-slate-700/30`}
ref={(el) => (contentRefs.current[block.id] = el)}
onMouseEnter={() => setHoveredBlockId(block.id)}
onMouseLeave={() => setHoveredBlockId(null)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
>
<h2 className="text-2xl font-semibold mb-1 text-blue-500">{block.title}</h2>
{
renderTypeBlock(block, isHovered, isActive, isEditing)
}
</motion.div>
);
};

View File

@ -4,16 +4,54 @@ export const mockScriptData: ScriptData = {
blocks: [
{
id: 'core',
title: "SCENE'S CORE CONFLICT",
title: "SUMMARY",
type: 'core',
content: [
{
type: 'paragraph',
text: 'The desperate, on-stage improvisation of a failing comedian triggers a physical and professional collapse, forcing him and his partner to confront the absurd meaninglessness of their ambitions. This scene serves as the inciting incident and rising action, where the internal conflict of artistic decay manifests as a literal, catastrophic failure, propelling them from a state of professional anxiety into a fight for survival.'
}
]
},
{
id: 'theme',
title: 'THEME',
type: 'theme',
content: [
{
type: 'tag',
text: 'Satire'
},
{
type: 'tag',
text: 'Absurdist Comedy'
},
{
type: 'tag',
text: 'Disaster'
}
]
},
{
id: 'roles',
title: 'ROLES',
type: 'roles',
content: [
{
type: 'bold',
text: 'Anna'
},
{
type: 'paragraph',
text: 'Anna is a young woman who is trying to find her place in the world. She is a bit of a mess, but she is also a bit of a mess.'
},
{
type: 'bold',
text: 'GENRE: Satire | Absurdist Comedy | Disaster'
text: 'Mark'
},
{
type: 'card',
text: 'Mark starts as resentful and aimless, harboring guilt over past family conflicts. His involvement in Annas quest rekindles his sense of responsibility and belonging. He moves from avoidance and cynicism to active support, ultimately risking his own safety for Anna and the creature. Marks arc is one of redemption and reconnection.'
}
]
},

View File

@ -2,13 +2,20 @@ export interface ScriptBlock {
id: string;
title: string;
content: ScriptContent[];
type: 'core' | 'scene' | 'summary';
type: 'core' | 'scene' | 'summary' | 'theme' | 'roles';
sceneNumber?: number;
}
export interface ScriptContent {
type: 'paragraph' | 'bold' | 'italic' | 'heading';
text: string;
type: 'paragraph' | 'bold' | 'italic' | 'heading' | 'tag' | 'card';
text?: string;
roleInfo?: {
name: string;
gender: string;
role: string;
desc: string;
color: string;
};
}
export interface ScriptData {
@ -24,3 +31,32 @@ export interface NavigationItem {
title: string;
type: 'core' | 'scene' | 'summary';
}
// 主题标签背景色 enum
export enum ThemeTagBgColor {
blue = 'bg-blue-500/20',
green = 'bg-green-500/20',
red = 'bg-red-500/20',
yellow = 'bg-yellow-500/20',
purple = 'bg-purple-500/20',
orange = 'bg-orange-500/20',
pink = 'bg-pink-500/20',
brown = 'bg-brown-500/20',
gray = 'bg-gray-500/20'
}
// 主题类型 enum
export enum ThemeType {
satire = 'satire', // 讽刺
absurdistComedy = 'absurdistComedy', // 荒诞喜剧
disaster = 'disaster', // 灾难
tragedy = 'tragedy', // 悲剧
comedy = 'comedy', // 喜剧
drama = 'drama', // 戏剧
fantasy = 'fantasy', // 奇幻
horror = 'horror', // 恐怖
mystery = 'mystery', // 神秘
romance = 'romance', // 爱情
scienceFiction = 'scienceFiction', // 科幻
thriller = 'thriller', // 惊悚
}

View File

@ -13,6 +13,7 @@ interface SelectDropdownProps {
label: string;
options: SettingOption[];
value: string;
placeholder?: string;
onChange: (value: string) => void;
}
@ -22,6 +23,7 @@ export const SelectDropdown = (
label,
options,
value,
placeholder,
onChange
}: SelectDropdownProps
) => {
@ -35,7 +37,7 @@ export const SelectDropdown = (
<div className="relative w-full h-full">
<motion.button
className={cn(
"w-full px-4 py-2 rounded-lg border text-left flex items-center justify-between",
"w-full p-2 rounded-lg border text-left flex items-center justify-between",
openDropdown === dropdownId
? "border-blue-500 bg-blue-500/10"
: "border-white/10 hover:border-white/20"
@ -44,7 +46,10 @@ export const SelectDropdown = (
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
>
<div className="flex items-center gap-2 overflow-hidden text-ellipsis whitespace-nowrap">
<span>{options.find(opt => opt.value === value)?.label || value}</span>
{placeholder && <span className="text-gray-400/60">{placeholder}</span>}
</div>
<motion.div
animate={{ rotate: openDropdown === dropdownId ? 180 : 0 }}
transition={{ duration: 0.2 }}