工作流

This commit is contained in:
北枳 2025-07-03 05:51:01 +08:00
parent 72f7016680
commit 96a319240d
30 changed files with 2813 additions and 974 deletions

View File

@ -97,7 +97,7 @@ body {
} }
.hide-scrollbar::-webkit-scrollbar { .hide-scrollbar::-webkit-scrollbar {
display: none; display: none !important;
} }
@layer base { @layer base {

View File

@ -10,6 +10,12 @@ const OAuthCallbackHandler = dynamic(
{ ssr: false } { ssr: false }
); );
// Import dev helper in development environment only
const DevHelper = dynamic(
() => import('@/utils/dev-helper').then(() => ({ default: () => null })),
{ ssr: false }
);
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'AI Movie Flow - Create Amazing Videos with AI', title: 'AI Movie Flow - Create Amazing Videos with AI',
description: 'Professional AI-powered video creation platform with advanced editing tools', description: 'Professional AI-powered video creation platform with advanced editing tools',
@ -32,6 +38,7 @@ export default function RootLayout({
{children} {children}
<Toaster /> <Toaster />
<OAuthCallbackHandler /> <OAuthCallbackHandler />
{process.env.NODE_ENV === 'development' && <DevHelper />}
</ThemeProvider> </ThemeProvider>
</body> </body>
</html> </html>

View File

@ -88,7 +88,7 @@ export function AISuggestionBar({
</div> </div>
<div className="max-w-5xl mx-auto px-6"> <div className="max-w-5xl mx-auto px-6">
{/* 智能预设词条 */} {/* 智能预设词条 英文 */}
<AnimatePresence> <AnimatePresence>
{showSuggestions && !isCollapsed && ( {showSuggestions && !isCollapsed && (
<motion.div <motion.div
@ -120,7 +120,7 @@ export function AISuggestionBar({
> >
<Lightbulb className="w-4 h-4 text-yellow-500" /> <Lightbulb className="w-4 h-4 text-yellow-500" />
</motion.div> </motion.div>
<span className="text-sm text-white/60"></span> <span className="text-sm text-white/60">Smart preset tags</span>
</motion.div> </motion.div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{suggestions.map((suggestion, index) => ( {suggestions.map((suggestion, index) => (

View File

@ -34,7 +34,7 @@
color: var(--text-primary); color: var(--text-primary);
text-align: center; text-align: center;
letter-spacing: -.32px; letter-spacing: -.32px;
font-size: 32px; font-size: 1.25rem;
font-weight: 600; font-weight: 600;
line-height: 36px; line-height: 36px;
} }

View File

@ -4,6 +4,7 @@ import "./style/work-flow.css";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { AISuggestionBar } from "@/components/ai-suggestion-bar"; import { AISuggestionBar } from "@/components/ai-suggestion-bar";
import { EditModal } from "@/components/ui/edit-modal"; import { EditModal } from "@/components/ui/edit-modal";
import { ErrorBoundary } from "@/components/ui/error-boundary";
import { TaskInfo } from "./work-flow/task-info"; import { TaskInfo } from "./work-flow/task-info";
import { MediaViewer } from "./work-flow/media-viewer"; import { MediaViewer } from "./work-flow/media-viewer";
import { ThumbnailGrid } from "./work-flow/thumbnail-grid"; import { ThumbnailGrid } from "./work-flow/thumbnail-grid";
@ -27,6 +28,10 @@ export default function WorkFlow() {
isGeneratingSketch, isGeneratingSketch,
isGeneratingVideo, isGeneratingVideo,
currentLoadingText, currentLoadingText,
totalSketchCount,
roles,
music,
final,
setCurrentSketchIndex, setCurrentSketchIndex,
} = useWorkflowData(); } = useWorkflowData();
@ -35,29 +40,97 @@ export default function WorkFlow() {
isVideoPlaying, isVideoPlaying,
showControls, showControls,
setShowControls, setShowControls,
setIsPlaying,
togglePlay, togglePlay,
toggleVideoPlay, toggleVideoPlay,
playTimerRef, playTimerRef,
} = usePlaybackControls(taskSketch, taskVideos, currentStep); } = usePlaybackControls(taskSketch, taskVideos, currentStep);
// 跟踪是否已经自动开始播放过,避免重复触发
const hasAutoStartedRef = useRef(false);
// 调试:监控关键状态变化
useEffect(() => {
console.log('工作流状态:', {
currentStep,
isGeneratingSketch,
isGeneratingVideo,
isPlaying,
taskSketchLength: taskSketch.length,
sketchCount,
totalSketchCount
});
}, [isGeneratingSketch, taskSketch.length, sketchCount, totalSketchCount, currentStep, isPlaying]);
// 专门监控isPlaying状态变化
useEffect(() => {
console.log('播放状态变化:', isPlaying ? '开始播放' : '停止播放');
}, [isPlaying]);
// 检查分镜数据
useEffect(() => {
if (taskSketch.length > 0) {
console.log('分镜数据:', `${taskSketch.length}个分镜,当前索引:${currentSketchIndex}`);
}
}, [taskSketch.length, currentSketchIndex]);
// 第一个分镜视频生成完成时停止循环播放并切换到第一个
useEffect(() => {
if (taskVideos.length === 1 && isPlaying) {
console.log('第一个分镜视频生成完成,停止循环播放并切换到第一个分镜');
setIsPlaying(false); // 停止循环播放
setCurrentSketchIndex(0); // 切换到第一个分镜
}
}, [taskVideos.length, isPlaying, setIsPlaying, setCurrentSketchIndex]);
// 分镜草图生成完毕后自动开始播放
useEffect(() => {
if (
!isGeneratingSketch && // 分镜草图生成完毕
taskSketch.length > 0 && // 有分镜草图数据
sketchCount === totalSketchCount && // 确保所有分镜草图都生成完毕
(currentStep === '1' || currentStep === '2') && // 允许在步骤1或步骤2初期触发
!hasAutoStartedRef.current && // 还没有自动开始过播放
!isPlaying // 当前没有播放
) {
console.log('所有分镜草图生成完毕,自动开始播放');
// 添加小延迟确保状态完全更新
setTimeout(() => {
hasAutoStartedRef.current = true;
setIsPlaying(true); // 自动开始播放
}, 500);
}
// 当切换到步骤3及以后时重置标记
if (Number(currentStep) >= 3) {
hasAutoStartedRef.current = false;
}
}, [isGeneratingSketch, taskSketch.length, sketchCount, totalSketchCount, currentStep, isPlaying, setIsPlaying]);
// 处理自动播放的分镜切换逻辑 // 处理自动播放的分镜切换逻辑
useEffect(() => { useEffect(() => {
if (isPlaying && taskSketch.length > 0 && playTimerRef.current) { if (isPlaying && taskSketch.length > 0) {
console.log('开始自动切换分镜,总数:', taskSketch.length);
const interval = setInterval(() => { const interval = setInterval(() => {
setCurrentSketchIndex((prev: number) => (prev + 1) % taskSketch.length); setCurrentSketchIndex((prev: number) => {
}, 2000); const nextIndex = (prev + 1) % taskSketch.length;
return nextIndex;
});
}, 1000);
return () => clearInterval(interval); return () => {
clearInterval(interval);
};
} }
}, [isPlaying, taskSketch.length, setCurrentSketchIndex]); }, [isPlaying, taskSketch.length, setCurrentSketchIndex]);
// 模拟 AI 建议 // 模拟 AI 建议 英文
const mockSuggestions = [ const mockSuggestions = [
"优化场景转场效果", "Refine scene transitions",
"调整画面构图", "Adjust scene composition",
"改进角色动作设计", "Improve character action design",
"增加环境氛围", "Add environmental atmosphere",
"调整镜头语言" "Adjust lens language"
]; ];
const handleEditModalOpen = (tab: string) => { const handleEditModalOpen = (tab: string) => {
@ -74,80 +147,99 @@ export default function WorkFlow() {
}; };
return ( return (
<div className="w-full h-full overflow-hidden"> <ErrorBoundary>
<div className="flex h-full flex-col p-6 justify-center items-center pt-0"> <div className="w-full h-full overflow-hidden">
<div className="container-H2sRZG"> <div className="flex h-full flex-col p-6 justify-center items-center pt-0">
<div className="splashContainer-otuV_A"> <div className="container-H2sRZG">
<div className="content-vPGYx8"> <div className="splashContainer-otuV_A">
<div className="info-UUGkPJ"> <div className="content-vPGYx8">
<TaskInfo <div className="info-UUGkPJ">
isLoading={isLoading} <ErrorBoundary>
taskObject={taskObject} <TaskInfo
currentLoadingText={currentLoadingText} isLoading={isLoading}
/> taskObject={taskObject}
currentLoadingText={currentLoadingText}
/>
</ErrorBoundary>
</div>
</div> </div>
</div> <div className="media-Ocdu1O">
<div className="media-Ocdu1O"> <div
<div className="videoContainer-qteKNi"
className="videoContainer-qteKNi" style={currentStep !== '6' ? { flex: 3 } : {}}
style={currentStep !== '6' ? { flex: 3 } : {}} ref={containerRef}
ref={containerRef} >
> {isLoading ? (
{isLoading ? ( <Skeleton className="w-full aspect-video rounded-lg" />
<Skeleton className="w-full aspect-video rounded-lg" /> ) : (
) : ( <div className="heroVideo-FIzuK1" style={{ aspectRatio: "16 / 9" }}>
<div className="heroVideo-FIzuK1" style={{ aspectRatio: "16 / 9" }}> <ErrorBoundary>
<MediaViewer <MediaViewer
currentStep={currentStep}
currentSketchIndex={currentSketchIndex}
taskSketch={taskSketch}
taskVideos={taskVideos}
isVideoPlaying={isVideoPlaying}
isPlaying={isPlaying}
showControls={showControls}
isGeneratingSketch={isGeneratingSketch}
isGeneratingVideo={isGeneratingVideo}
onControlsChange={setShowControls}
onEditModalOpen={handleEditModalOpen}
onToggleVideoPlay={toggleVideoPlay}
onTogglePlay={togglePlay}
final={final}
/>
</ErrorBoundary>
</div>
)}
</div>
<div className="imageGrid-ymZV9z hide-scrollbar">
<ErrorBoundary>
<ThumbnailGrid
isLoading={isLoading}
currentStep={currentStep} currentStep={currentStep}
currentSketchIndex={currentSketchIndex} currentSketchIndex={currentSketchIndex}
taskSketch={taskSketch} taskSketch={taskSketch}
taskVideos={taskVideos} taskVideos={taskVideos}
isVideoPlaying={isVideoPlaying} isGeneratingSketch={isGeneratingSketch}
isPlaying={isPlaying} isGeneratingVideo={isGeneratingVideo}
showControls={showControls} sketchCount={sketchCount}
onControlsChange={setShowControls} totalSketchCount={totalSketchCount}
onEditModalOpen={handleEditModalOpen} onSketchSelect={setCurrentSketchIndex}
onToggleVideoPlay={toggleVideoPlay}
onTogglePlay={togglePlay}
/> />
</div> </ErrorBoundary>
)} </div>
</div>
<div className="imageGrid-ymZV9z hidden-scrollbar">
<ThumbnailGrid
isLoading={isLoading}
currentStep={currentStep}
currentSketchIndex={currentSketchIndex}
taskSketch={taskSketch}
taskVideos={taskVideos}
isGeneratingSketch={isGeneratingSketch}
sketchCount={sketchCount}
onSketchSelect={setCurrentSketchIndex}
/>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{/* AI 建议栏 */}
<ErrorBoundary>
<AISuggestionBar
suggestions={mockSuggestions}
onSuggestionClick={handleSuggestionClick}
onSubmit={handleSubmit}
placeholder="Please input your ideas, or click the predefined tags to receive AI advice..."
/>
</ErrorBoundary>
<ErrorBoundary>
<EditModal
isOpen={isEditModalOpen}
activeEditTab={activeEditTab}
onClose={() => setIsEditModalOpen(false)}
taskStatus={taskObject?.taskStatus || '1'}
taskSketch={taskSketch}
sketchVideo={taskVideos}
currentSketchIndex={currentSketchIndex}
onSketchSelect={setCurrentSketchIndex}
roles={roles}
music={music}
/>
</ErrorBoundary>
</div> </div>
</ErrorBoundary>
{/* AI 建议栏 */}
<AISuggestionBar
suggestions={mockSuggestions}
onSuggestionClick={handleSuggestionClick}
onSubmit={handleSubmit}
placeholder="请输入你的想法,或点击预设词条获取 AI 建议..."
/>
<EditModal
isOpen={isEditModalOpen}
activeEditTab={activeEditTab}
onClose={() => setIsEditModalOpen(false)}
taskStatus={taskObject?.taskStatus || '1'}
taskSketch={taskSketch}
sketchVideo={taskVideos}
currentSketchIndex={currentSketchIndex}
onSketchSelect={setCurrentSketchIndex}
/>
</div>
) )
} }

View File

@ -14,18 +14,15 @@ interface MediaViewerProps {
isVideoPlaying: boolean; isVideoPlaying: boolean;
isPlaying: boolean; isPlaying: boolean;
showControls: boolean; showControls: boolean;
isGeneratingSketch: boolean;
isGeneratingVideo: boolean;
onControlsChange: (show: boolean) => void; onControlsChange: (show: boolean) => void;
onEditModalOpen: (tab: string) => void; onEditModalOpen: (tab: string) => void;
onToggleVideoPlay: () => void; onToggleVideoPlay: () => void;
onTogglePlay: () => void; onTogglePlay: () => void;
final?: any;
} }
const MOCK_FINAL_VIDEO = {
id: 'final-video',
url: 'https://cdn.qikongjian.com/videos/1750389908_37d4fffa-8516-43a3-a423-fc0274f40e8a_text_to_video_0.mp4',
thumbnail: 'https://d3phaj0sisr2ct.cloudfront.net/app/gen4/object-reference/welcome-ref-1.jpg',
};
export function MediaViewer({ export function MediaViewer({
currentStep, currentStep,
currentSketchIndex, currentSketchIndex,
@ -34,10 +31,13 @@ export function MediaViewer({
isVideoPlaying, isVideoPlaying,
isPlaying, isPlaying,
showControls, showControls,
isGeneratingSketch,
isGeneratingVideo,
onControlsChange, onControlsChange,
onEditModalOpen, onEditModalOpen,
onToggleVideoPlay, onToggleVideoPlay,
onTogglePlay onTogglePlay,
final
}: MediaViewerProps) { }: MediaViewerProps) {
const mainVideoRef = useRef<HTMLVideoElement>(null); const mainVideoRef = useRef<HTMLVideoElement>(null);
@ -67,140 +67,211 @@ export function MediaViewer({
}, [currentSketchIndex]); }, [currentSketchIndex]);
// 渲染最终成片 // 渲染最终成片
const renderFinalVideo = () => ( const renderFinalVideo = () => {
<div // 使用真实的final数据如果没有则使用默认值
className="relative w-full h-full rounded-lg overflow-hidden" const finalVideo = final || {
onMouseEnter={() => onControlsChange(true)} url: 'https://cdn.qikongjian.com/videos/1750389908_37d4fffa-8516-43a3-a423-fc0274f40e8a_text_to_video_0.mp4'
onMouseLeave={() => onControlsChange(false)} };
>
<div className="relative w-full h-full">
{/* 背景模糊的视频 */}
<motion.div
className="absolute inset-0 overflow-hidden"
initial={{ filter: "blur(0px)", scale: 1, opacity: 1 }}
animate={{ filter: "blur(20px)", scale: 1.1, opacity: 0.5 }}
transition={{ duration: 0.8, ease: "easeInOut" }}
>
<video
className="w-full h-full rounded-lg object-cover object-center"
src={taskVideos[currentSketchIndex]?.url}
autoPlay
loop
muted
playsInline
/>
</motion.div>
{/* 最终成片视频 */} return (
<motion.div <div
initial={{ clipPath: "inset(0 50% 0 50%)", filter: "blur(10px)" }} className="relative w-full h-full rounded-lg overflow-hidden"
animate={{ clipPath: "inset(0 0% 0 0%)", filter: "blur(0px)" }} onMouseEnter={() => onControlsChange(true)}
transition={{ onMouseLeave={() => onControlsChange(false)}
clipPath: { duration: 1.2, ease: [0.43, 0.13, 0.23, 0.96] }, >
filter: { duration: 0.6, delay: 0.3 } <div className="relative w-full h-full">
}} {/* 背景模糊的视频 */}
className="relative z-10" <motion.div
> className="absolute inset-0 overflow-hidden"
<video initial={{ filter: "blur(0px)", scale: 1, opacity: 1 }}
className="w-full h-full object-cover rounded-lg" animate={{ filter: "blur(20px)", scale: 1.1, opacity: 0.5 }}
src={MOCK_FINAL_VIDEO.url} transition={{ duration: 0.8, ease: "easeInOut" }}
poster={MOCK_FINAL_VIDEO.thumbnail} >
autoPlay <video
loop className="w-full h-full rounded-lg object-cover object-center"
muted src={taskVideos[currentSketchIndex]?.url}
playsInline autoPlay
/> loop
</motion.div> playsInline
/>
</motion.div>
{/* 操作按钮组 */} {/* 最终成片视频 */}
<AnimatePresence> <motion.div
{showControls && ( initial={{ clipPath: "inset(0 50% 0 50%)", filter: "blur(10px)" }}
<motion.div animate={{ clipPath: "inset(0 0% 0 0%)", filter: "blur(0px)" }}
className="absolute top-4 right-4 z-10 flex gap-2" transition={{
initial={{ opacity: 0, y: -10 }} clipPath: { duration: 1.2, ease: [0.43, 0.13, 0.23, 0.96] },
animate={{ opacity: 1, y: 0 }} filter: { duration: 0.6, delay: 0.3 }
exit={{ opacity: 0, y: -10 }} }}
transition={{ duration: 0.2 }} className="relative z-10"
> >
<GlassIconButton <video
icon={Edit3} className="w-full h-full object-cover rounded-lg"
tooltip="编辑分镜" src={finalVideo.url}
onClick={() => onEditModalOpen('4')} poster={taskSketch[currentSketchIndex]?.url}
/> autoPlay
</motion.div> loop
)} playsInline
</AnimatePresence> />
</motion.div>
{/* 视频信息浮层 */} {/* 操作按钮组 */}
<motion.div <AnimatePresence>
className="absolute bottom-0 left-0 right-0 z-10 p-4 bg-gradient-to-t from-black/80 via-black/40 to-transparent" {showControls && (
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 1, duration: 0.6 }}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<motion.div <motion.div
className="w-2 h-2 rounded-full bg-emerald-500" className="absolute top-4 right-4 z-10 flex gap-2"
animate={{ initial={{ opacity: 0, y: -10 }}
scale: [1, 1.2, 1], animate={{ opacity: 1, y: 0 }}
opacity: [1, 0.6, 1] exit={{ opacity: 0, y: -10 }}
}} transition={{ duration: 0.2 }}
transition={{ >
duration: 2, <GlassIconButton
repeat: Infinity, icon={Edit3}
ease: "easeInOut" tooltip="Edit sketch"
}} onClick={() => onEditModalOpen('4')}
/> />
<span className="text-sm font-medium text-white/90"></span> </motion.div>
</div> )}
</div> </AnimatePresence>
</motion.div>
{/* 完成标记 */} {/* 视频信息浮层 */}
<motion.div <motion.div
className="absolute top-4 right-4 px-3 py-1.5 rounded-full bg-emerald-500/20 backdrop-blur-sm className="absolute bottom-0 left-0 right-0 z-10 p-4 bg-gradient-to-t from-black/80 via-black/40 to-transparent"
border border-emerald-500/30 text-emerald-400 text-sm font-medium" initial={{ opacity: 0, y: 20 }}
initial={{ opacity: 0, scale: 0.8, x: 20 }} animate={{ opacity: 1, y: 0 }}
animate={{ opacity: 1, scale: 1, x: 0 }} transition={{ delay: 1, duration: 0.6 }}
transition={{ delay: 1.2, duration: 0.6 }} >
> <div className="flex items-center justify-between">
<div className="flex items-center gap-3">
</motion.div> <motion.div
className="w-2 h-2 rounded-full bg-emerald-500"
animate={{
scale: [1, 1.2, 1],
opacity: [1, 0.6, 1]
}}
transition={{
duration: 2,
repeat: Infinity,
ease: "easeInOut"
}}
/>
<span className="text-sm font-medium text-white/90">Final product</span>
</div>
</div>
</motion.div>
{/* 完成标记 */}
<motion.div
className="absolute top-4 right-4 px-3 py-1.5 rounded-full bg-emerald-500/20 backdrop-blur-sm
border border-emerald-500/30 text-emerald-400 text-sm font-medium"
initial={{ opacity: 0, scale: 0.8, x: 20 }}
animate={{ opacity: 1, scale: 1, x: 0 }}
transition={{ delay: 1.2, duration: 0.6 }}
>
Task completed
</motion.div>
</div>
</div> </div>
</div> );
); };
// 渲染视频内容 // 渲染视频内容
const renderVideoContent = () => ( const renderVideoContent = () => {
<div const currentSketch = taskSketch[currentSketchIndex];
className="relative w-full h-full rounded-lg" const defaultBgColors = ['RGB(45, 50, 70)', 'RGB(75, 80, 100)', 'RGB(105, 110, 130)'];
onMouseEnter={() => onControlsChange(true)} const bgColors = currentSketch?.bg_rgb || defaultBgColors;
onMouseLeave={() => onControlsChange(false)}
> return (
{taskVideos[currentSketchIndex] ? ( <div
<ProgressiveReveal className="relative w-full h-full rounded-lg"
className="w-full h-full rounded-lg" onMouseEnter={() => onControlsChange(true)}
customVariants={{ onMouseLeave={() => onControlsChange(false)}
hidden: { >
opacity: 0, {/* 只在生成过程中或没有视频时使用ProgressiveReveal */}
filter: "blur(20px)", {(isGeneratingVideo || !taskVideos[currentSketchIndex]) ? (
clipPath: "inset(0 100% 0 0)" taskVideos[currentSketchIndex] ? (
}, <ProgressiveReveal
visible: { key={`generte-video-${currentSketchIndex}`}
opacity: 1, className="w-full h-full rounded-lg"
filter: "blur(0px)", revealDuration={0.8}
clipPath: "inset(0 0% 0 0)", blurDuration={0.3}
transition: { initialBlur={10}
duration: 1, customVariants={{
ease: [0.43, 0.13, 0.23, 0.96], hidden: {
opacity: { duration: 0.8, ease: "easeOut" }, opacity: 0,
filter: { duration: 0.6, ease: "easeOut" }, scale: 0.9,
clipPath: { duration: 0.8, ease: "easeInOut" } filter: "blur(30px)",
} clipPath: "inset(0 100% 0 0)"
} },
}} visible: {
> opacity: 1,
scale: 1,
filter: "blur(0px)",
clipPath: "inset(0 0% 0 0)",
transition: {
duration: 1.5,
ease: [0.23, 1, 0.32, 1],
opacity: { duration: 0.8, ease: "easeOut" },
scale: { duration: 1.2, ease: "easeOut" },
filter: { duration: 0.8, delay: 0.4, ease: "easeOut" },
clipPath: { duration: 1, ease: "easeInOut" }
}
}
}}
loadingBgConfig={{
fromColor: `from-[${bgColors[0]}]`,
viaColor: `via-[${bgColors[1]}]`,
toColor: `to-[${bgColors[2]}]`,
glowOpacity: 0.8,
duration: 4,
}}
>
<div className="relative w-full h-full">
{/* 背景模糊的图片 */}
<div className="absolute inset-0 overflow-hidden">
<img
className="w-full h-full object-cover filter blur-lg scale-110 opacity-50"
src={taskSketch[currentSketchIndex]?.url}
alt="background"
/>
</div>
{/* 视频 */}
<motion.div
initial={{ clipPath: "inset(0 100% 0 0)" }}
animate={{ clipPath: "inset(0 0% 0 0)" }}
transition={{ duration: 0.8, ease: [0.43, 0.13, 0.23, 0.96] }}
className="relative z-10"
>
<video
ref={mainVideoRef}
key={taskVideos[currentSketchIndex].url}
className="w-full h-full rounded-lg object-cover object-center"
src={taskVideos[currentSketchIndex].url}
autoPlay={isVideoPlaying}
loop={true}
playsInline
onEnded={() => {
if (isVideoPlaying) {
// 自动切换到下一个视频的逻辑在父组件处理
}
}}
/>
</motion.div>
</div>
</ProgressiveReveal>
) : (
<div className="w-full h-full flex items-center justify-center rounded-lg overflow-hidden relative">
<img
className="absolute inset-0 w-full h-full object-cover"
src={taskSketch[currentSketchIndex]?.url}
alt={`Sketch ${currentSketchIndex + 1}`}
/>
</div>
)
) : (
/* 生成完成后直接显示视频不使用ProgressiveReveal */
<div className="relative w-full h-full"> <div className="relative w-full h-full">
{/* 背景模糊的图片 */} {/* 背景模糊的图片 */}
<div className="absolute inset-0 overflow-hidden"> <div className="absolute inset-0 overflow-hidden">
@ -211,218 +282,281 @@ export function MediaViewer({
/> />
</div> </div>
{/* 视频 */} {/* 视频 修复播放没有声音 */}
<video
ref={mainVideoRef}
key={taskVideos[currentSketchIndex].url}
className="w-full h-full rounded-lg object-cover object-center relative z-10"
src={taskVideos[currentSketchIndex].url}
autoPlay={isVideoPlaying}
loop={true}
playsInline
onEnded={() => {
if (isVideoPlaying) {
// 自动切换到下一个视频的逻辑在父组件处理
}
}}
/>
</div>
)}
{/* 操作按钮组 */}
<AnimatePresence>
{showControls && (
<motion.div <motion.div
initial={{ clipPath: "inset(0 100% 0 0)" }} className="absolute top-4 right-4 flex gap-2 z-[11]"
animate={{ clipPath: "inset(0 0% 0 0)" }} initial={{ opacity: 0, y: -10 }}
transition={{ duration: 0.8, ease: [0.43, 0.13, 0.23, 0.96] }} animate={{ opacity: 1, y: 0 }}
className="relative z-10" exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
> >
<video <GlassIconButton
ref={mainVideoRef} icon={Edit3}
key={taskVideos[currentSketchIndex].url} tooltip="Edit sketch"
className="w-full h-full rounded-lg object-cover object-center" onClick={() => onEditModalOpen('3')}
src={taskVideos[currentSketchIndex].url}
autoPlay={isVideoPlaying}
muted
loop={false}
playsInline
poster={taskSketch[currentSketchIndex]?.url}
onEnded={() => {
if (isVideoPlaying) {
// 自动切换到下一个视频的逻辑在父组件处理
}
}}
/> />
</motion.div> </motion.div>
</div> )}
</ProgressiveReveal> </AnimatePresence>
) : (
<div className="w-full h-full flex items-center justify-center rounded-lg overflow-hidden relative">
<img
className="absolute inset-0 w-full h-full object-cover"
src={taskSketch[currentSketchIndex]?.url}
alt={`分镜草图 ${currentSketchIndex + 1}`}
/>
</div>
)}
{/* 操作按钮组 */} {/* 底部播放按钮 */}
<AnimatePresence> <AnimatePresence>
{showControls && (
<motion.div <motion.div
className="absolute top-4 right-4 flex gap-2" className="absolute bottom-4 left-4 z-[11]"
initial={{ opacity: 0, y: -10 }} initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, y: -10 }} exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
> >
<GlassIconButton <motion.div
icon={Edit3} whileHover={{ scale: 1.1 }}
tooltip="编辑分镜" whileTap={{ scale: 0.9 }}
onClick={() => onEditModalOpen('3')} className="relative"
/> >
{/* 播放时的发光效果 */}
{isVideoPlaying && (
<motion.div
className="absolute inset-0 rounded-full blur-md"
animate={{
scale: [1, 1.2, 1],
opacity: [0.5, 0.8, 0.5]
}}
transition={{
duration: 2,
repeat: Infinity,
ease: "easeInOut"
}}
/>
)}
<GlassIconButton
icon={isVideoPlaying ? Pause : Play}
tooltip={isVideoPlaying ? "Pause video" : "Play video"}
onClick={onToggleVideoPlay}
size="sm"
/>
</motion.div>
</motion.div> </motion.div>
)} </AnimatePresence>
</AnimatePresence> </div>
);
{/* 底部播放按钮 */} };
<AnimatePresence>
<motion.div
className="absolute bottom-4 left-4"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.2 }}
>
<motion.div
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<GlassIconButton
icon={isVideoPlaying ? Pause : Play}
tooltip={isVideoPlaying ? "暂停播放" : "自动播放"}
onClick={onToggleVideoPlay}
size="sm"
/>
</motion.div>
</motion.div>
</AnimatePresence>
</div>
);
// 渲染分镜草图 // 渲染分镜草图
const renderSketchContent = () => ( const renderSketchContent = () => {
<div const currentSketch = taskSketch[currentSketchIndex];
className="relative w-full h-full rounded-lg" const defaultBgColors = ['RGB(45, 50, 70)', 'RGB(75, 80, 100)', 'RGB(105, 110, 130)'];
onMouseEnter={() => onControlsChange(true)} const bgColors = currentSketch?.bg_rgb || defaultBgColors;
onMouseLeave={() => onControlsChange(false)}
> return (
{taskSketch[currentSketchIndex] ? ( <div
<ProgressiveReveal className="relative w-full h-full rounded-lg"
className="w-full h-full rounded-lg" onMouseEnter={() => onControlsChange(true)}
{...presets.main} onMouseLeave={() => onControlsChange(false)}
> >
{/* 只在生成过程中或没有分镜图片时使用ProgressiveReveal */}
{(isGeneratingSketch || !currentSketch) ? (
currentSketch ? (
<ProgressiveReveal
key={`sketch-generating-${currentSketchIndex}`}
className="w-full h-full rounded-lg"
revealDuration={0.8}
blurDuration={0.3}
initialBlur={10}
customVariants={{
hidden: {
opacity: 0,
scale: 0.9,
filter: "blur(30px)"
},
visible: {
opacity: 1,
scale: 1,
filter: "blur(0px)",
transition: {
duration: 1.5,
ease: [0.23, 1, 0.32, 1],
opacity: { duration: 0.8, ease: "easeOut" },
scale: { duration: 1.2, ease: "easeOut" },
filter: { duration: 0.8, delay: 0.4, ease: "easeOut" }
}
}
}}
loadingBgConfig={{
fromColor: `from-[${bgColors[0]}]`,
viaColor: `via-[${bgColors[1]}]`,
toColor: `to-[${bgColors[2]}]`,
glowOpacity: 0.8,
duration: 4,
}}
>
<img
key={currentSketchIndex}
src={currentSketch.url}
alt={`Sketch ${currentSketchIndex + 1}`}
className="w-full h-full rounded-lg object-cover"
/>
</ProgressiveReveal>
) : (
<div className="w-full h-full flex items-center justify-center rounded-lg overflow-hidden relative">
{/* 动态渐变背景 */}
<motion.div
className={`absolute inset-0 bg-gradient-to-r from-[${bgColors[0]}] via-[${bgColors[1]}] to-[${bgColors[2]}]`}
animate={{
backgroundPosition: ["0% 50%", "100% 50%", "0% 50%"],
}}
transition={{
duration: 5,
repeat: Infinity,
ease: "linear"
}}
style={{
backgroundSize: "200% 200%",
}}
/>
{/* 动态光效 */}
<motion.div
className="absolute inset-0 opacity-50"
style={{
background: "radial-gradient(circle at center, rgba(255,255,255,0.8) 0%, transparent 50%)",
}}
animate={{
scale: [1, 1.2, 1],
}}
transition={{
duration: 2,
repeat: Infinity,
ease: "easeInOut"
}}
/>
<motion.div
className="flex flex-col items-center gap-4 relative z-10"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<div className="relative">
<motion.div
className="absolute -inset-4 bg-gradient-to-r from-white via-sky-200 to-cyan-200 rounded-full opacity-60 blur-xl"
animate={{
scale: [1, 1.2, 1],
rotate: [0, 180, 360],
}}
transition={{
duration: 4,
repeat: Infinity,
ease: "linear"
}}
/>
</div>
</motion.div>
</div>
)
) : (
/* 生成完成后直接显示图片不使用ProgressiveReveal */
<img <img
key={currentSketchIndex} key={currentSketchIndex}
src={taskSketch[currentSketchIndex].url} src={currentSketch.url}
alt={`分镜草图 ${currentSketchIndex + 1}`} alt={`Sketch ${currentSketchIndex + 1}`}
className="w-full h-full rounded-lg object-cover" className="w-full h-full rounded-lg object-cover"
/> />
</ProgressiveReveal> )}
) : (
<div className="w-full h-full flex items-center justify-center rounded-lg overflow-hidden relative">
{/* 动态渐变背景 */}
<motion.div
className="absolute inset-0 bg-gradient-to-r from-cyan-300 via-sky-400 to-blue-500"
animate={{
backgroundPosition: ["0% 50%", "100% 50%", "0% 50%"],
}}
transition={{
duration: 5,
repeat: Infinity,
ease: "linear"
}}
style={{
backgroundSize: "200% 200%",
}}
/>
{/* 动态光效 */}
<motion.div
className="absolute inset-0 opacity-50"
style={{
background: "radial-gradient(circle at center, rgba(255,255,255,0.8) 0%, transparent 50%)",
}}
animate={{
scale: [1, 1.2, 1],
}}
transition={{
duration: 2,
repeat: Infinity,
ease: "easeInOut"
}}
/>
<motion.div
className="flex flex-col items-center gap-4 relative z-10"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<div className="relative">
<motion.div
className="absolute -inset-4 bg-gradient-to-r from-white via-sky-200 to-cyan-200 rounded-full opacity-60 blur-xl"
animate={{
scale: [1, 1.2, 1],
rotate: [0, 180, 360],
}}
transition={{
duration: 4,
repeat: Infinity,
ease: "linear"
}}
/>
</div>
</motion.div>
</div>
)}
{/* 操作按钮组 */} {/* 操作按钮组 */}
<AnimatePresence> <AnimatePresence>
{showControls && ( {showControls && (
<motion.div
className="absolute top-4 right-4 flex gap-2"
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
>
<GlassIconButton
icon={Edit3}
tooltip="Edit sketch"
onClick={() => onEditModalOpen('1')}
/>
</motion.div>
)}
</AnimatePresence>
{/* 底部播放按钮 */}
<AnimatePresence>
<motion.div <motion.div
className="absolute top-4 right-4 flex gap-2" className="absolute bottom-4 left-4"
initial={{ opacity: 0, y: -10 }} initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, y: -10 }} exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
> >
<GlassIconButton <motion.div
icon={Edit3} whileHover={{ scale: 1.1 }}
tooltip="编辑分镜" whileTap={{ scale: 0.9 }}
onClick={() => onEditModalOpen('1')} className="relative"
/> >
{/* 播放时的发光效果 */}
{isPlaying && (
<motion.div
className="absolute inset-0 rounded-full bg-blue-500/30 blur-md"
animate={{
scale: [1, 1.2, 1],
opacity: [0.5, 0.8, 0.5]
}}
transition={{
duration: 2,
repeat: Infinity,
ease: "easeInOut"
}}
/>
)}
<GlassIconButton
icon={isPlaying ? Pause : Play}
tooltip={isPlaying ? "Pause auto play" : "Start auto play"}
onClick={onTogglePlay}
size="sm"
className={isPlaying ? "border-blue-500/50 bg-blue-500/10" : ""}
/>
</motion.div>
</motion.div> </motion.div>
)} </AnimatePresence>
</AnimatePresence>
{/* 底部播放按钮 */} {/* 播放进度指示器 */}
<AnimatePresence> <AnimatePresence>
<motion.div {isPlaying && (
className="absolute bottom-4 left-4" <motion.div
initial={{ opacity: 0, scale: 0.8 }} className="absolute bottom-0 left-0 right-0 h-1 bg-blue-500/20"
animate={{ opacity: 1, scale: 1 }} initial={{ scaleX: 0 }}
exit={{ opacity: 0, scale: 0.8 }} animate={{ scaleX: 1 }}
transition={{ duration: 0.2 }} exit={{ scaleX: 0 }}
> transition={{ duration: 2, repeat: Infinity }}
<motion.div style={{ transformOrigin: "left" }}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<GlassIconButton
icon={isPlaying ? Pause : Play}
tooltip={isPlaying ? "暂停播放" : "自动播放"}
onClick={onTogglePlay}
size="sm"
/> />
</motion.div> )}
</motion.div> </AnimatePresence>
</AnimatePresence> </div>
);
{/* 播放进度指示器 */} };
<AnimatePresence>
{isPlaying && (
<motion.div
className="absolute bottom-0 left-0 right-0 h-1 bg-blue-500/20"
initial={{ scaleX: 0 }}
animate={{ scaleX: 1 }}
exit={{ scaleX: 0 }}
transition={{ duration: 2, repeat: Infinity }}
style={{ transformOrigin: "left" }}
/>
)}
</AnimatePresence>
</div>
);
// 根据当前步骤渲染对应内容 // 根据当前步骤渲染对应内容
if (Number(currentStep) === 6) { if (Number(currentStep) === 6) {

View File

@ -26,7 +26,7 @@ export function TaskInfo({ isLoading, taskObject, currentLoadingText }: TaskInfo
{taskObject?.projectName}{taskObject?.taskName} {taskObject?.projectName}{taskObject?.taskName}
</div> </div>
{currentLoadingText === '任务完成' ? ( {currentLoadingText === 'Task completed' ? (
<motion.div <motion.div
className="flex items-center gap-3 justify-center" className="flex items-center gap-3 justify-center"
initial={{ opacity: 0, y: -10 }} initial={{ opacity: 0, y: -10 }}
@ -98,16 +98,93 @@ export function TaskInfo({ isLoading, taskObject, currentLoadingText }: TaskInfo
repeatDelay: 0.2 repeatDelay: 0.2
}} }}
/> />
<motion.p <motion.div
className="normalS400 subtitle-had8uE text-blue-500/80" className="relative"
key={currentLoadingText} key={currentLoadingText}
initial={{ opacity: 0, x: -10 }} initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 10 }} exit={{ opacity: 0, x: 10 }}
transition={{ duration: 0.3 }} transition={{ duration: 0.3 }}
> >
{currentLoadingText} {/* 背景发光效果 */}
</motion.p> <motion.div
className="absolute inset-0 text-transparent bg-clip-text bg-gradient-to-r from-blue-400 via-cyan-400 to-purple-400 blur-sm"
animate={{
backgroundPosition: ["0% 50%", "100% 50%", "0% 50%"],
}}
transition={{
duration: 2,
repeat: Infinity,
ease: "linear"
}}
style={{
backgroundSize: "200% 200%",
}}
>
<span className="normalS400 subtitle-had8uE">{currentLoadingText}</span>
</motion.div>
{/* 主文字 - 颜色填充动画 */}
<motion.div
className="relative z-10"
animate={{
scale: [1, 1.02, 1],
}}
transition={{
duration: 1.5,
repeat: Infinity,
ease: "easeInOut"
}}
>
<motion.span
className="normalS400 subtitle-had8uE text-transparent bg-clip-text bg-gradient-to-r from-blue-600 via-cyan-500 to-purple-600"
animate={{
backgroundPosition: ["0% 50%", "100% 50%", "0% 50%"],
}}
transition={{
duration: 3,
repeat: Infinity,
ease: "linear"
}}
style={{
backgroundSize: "300% 300%",
}}
>
{currentLoadingText}
</motion.span>
</motion.div>
{/* 动态光点效果 */}
<motion.div
className="absolute left-0 top-1/2 transform -translate-y-1/2 w-2 h-2 bg-gradient-to-r from-cyan-400 to-blue-500 rounded-full blur-sm"
animate={{
x: [0, 200, 0],
opacity: [0, 1, 0],
scale: [0.5, 1, 0.5],
}}
transition={{
duration: 2.5,
repeat: Infinity,
ease: "easeInOut",
}}
/>
{/* 文字底部装饰线 */}
<motion.div
className="absolute bottom-0 left-0 h-0.5 bg-gradient-to-r from-blue-500 via-cyan-400 to-purple-500"
animate={{
width: ["0%", "100%", "0%"],
backgroundPosition: ["0% 50%", "100% 50%", "0% 50%"],
}}
transition={{
width: { duration: 2, repeat: Infinity, ease: "easeInOut" },
backgroundPosition: { duration: 1.5, repeat: Infinity, ease: "linear" }
}}
style={{
backgroundSize: "200% 200%",
}}
/>
</motion.div>
<motion.div <motion.div
className="w-1.5 h-1.5 rounded-full bg-blue-500" className="w-1.5 h-1.5 rounded-full bg-blue-500"
animate={{ animate={{

View File

@ -12,12 +12,12 @@ interface ThumbnailGridProps {
taskSketch: any[]; taskSketch: any[];
taskVideos: any[]; taskVideos: any[];
isGeneratingSketch: boolean; isGeneratingSketch: boolean;
isGeneratingVideo: boolean;
sketchCount: number; sketchCount: number;
totalSketchCount: number;
onSketchSelect: (index: number) => void; onSketchSelect: (index: number) => void;
} }
const MOCK_SKETCH_COUNT = 8;
export function ThumbnailGrid({ export function ThumbnailGrid({
isLoading, isLoading,
currentStep, currentStep,
@ -25,7 +25,9 @@ export function ThumbnailGrid({
taskSketch, taskSketch,
taskVideos, taskVideos,
isGeneratingSketch, isGeneratingSketch,
isGeneratingVideo,
sketchCount, sketchCount,
totalSketchCount,
onSketchSelect onSketchSelect
}: ThumbnailGridProps) { }: ThumbnailGridProps) {
const thumbnailsRef = useRef<HTMLDivElement>(null); const thumbnailsRef = useRef<HTMLDivElement>(null);
@ -99,187 +101,244 @@ export function ThumbnailGrid({
} }
// 渲染生成中的缩略图 // 渲染生成中的缩略图
const renderGeneratingThumbnail = () => ( const renderGeneratingThumbnail = () => {
<motion.div const currentSketch = taskSketch[currentSketchIndex];
className="relative aspect-video rounded-lg overflow-hidden" const defaultBgColors = ['RGB(45, 50, 70)', 'RGB(75, 80, 100)', 'RGB(105, 110, 130)'];
initial={{ opacity: 0, scale: 0.8 }} const bgColors = currentSketch?.bg_rgb || defaultBgColors;
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3 }} return (
>
{/* 动态渐变背景 */}
<motion.div <motion.div
className="absolute inset-0 bg-gradient-to-r from-cyan-300 via-sky-400 to-blue-500" className="relative aspect-video rounded-lg overflow-hidden"
animate={{ initial={{ opacity: 0, scale: 0.8 }}
backgroundPosition: ["0% 50%", "100% 50%", "0% 50%"], animate={{ opacity: 1, scale: 1 }}
}} transition={{ duration: 0.3 }}
transition={{ >
duration: 5, {/* 动态渐变背景 */}
repeat: Infinity, <motion.div
ease: "linear" className={`absolute inset-0 bg-gradient-to-r from-[${bgColors[0]}] via-[${bgColors[1]}] to-[${bgColors[2]}]`}
}} animate={{
style={{ backgroundPosition: ["0% 50%", "100% 50%", "0% 50%"],
backgroundSize: "200% 200%", }}
}} transition={{
/> duration: 5,
{/* 动态光效 */} repeat: Infinity,
<motion.div ease: "linear"
className="absolute inset-0 opacity-50" }}
style={{ style={{
background: "radial-gradient(circle at center, rgba(255,255,255,0.8) 0%, transparent 50%)", backgroundSize: "200% 200%",
}} }}
animate={{ />
scale: [1, 1.2, 1], {/* 动态光效 */}
}} <motion.div
transition={{ className="absolute inset-0 opacity-50"
duration: 2, style={{
repeat: Infinity, background: "radial-gradient(circle at center, rgba(255,255,255,0.8) 0%, transparent 50%)",
ease: "easeInOut" }}
}} animate={{
/> scale: [1, 1.2, 1],
<div className="absolute inset-0 flex items-center justify-center"> }}
<div className="relative"> transition={{
<motion.div duration: 2,
className="absolute -inset-4 bg-gradient-to-r from-white via-sky-200 to-cyan-200 rounded-full opacity-60 blur-xl" repeat: Infinity,
animate={{ ease: "easeInOut"
scale: [1, 1.2, 1], }}
rotate: [0, 180, 360], />
}} <div className="absolute inset-0 flex items-center justify-center">
transition={{ <div className="relative">
duration: 4, <motion.div
repeat: Infinity, className="absolute -inset-4 bg-gradient-to-r from-white via-sky-200 to-cyan-200 rounded-full opacity-60 blur-xl"
ease: "linear" animate={{
}} scale: [1, 1.2, 1],
/> rotate: [0, 180, 360],
}}
transition={{
duration: 4,
repeat: Infinity,
ease: "linear"
}}
/>
</div>
</div> </div>
</div> <div className="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/60 to-transparent">
<div className="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/60 to-transparent"> <span className="text-xs text-white/90">Scene {sketchCount + 1}</span>
<span className="text-xs text-white/90"> {sketchCount + 1}</span> </div>
</div> </motion.div>
</motion.div> );
); };
// 渲染视频阶段的缩略图 // 渲染视频阶段的缩略图
const renderVideoThumbnails = () => ( const renderVideoThumbnails = () => (
taskSketch.map((sketch, index) => ( taskSketch.map((sketch, index) => {
<div const defaultBgColors = ['RGB(45, 50, 70)', 'RGB(75, 80, 100)', 'RGB(105, 110, 130)'];
key={`video-${index}`} const bgColors = sketch?.bg_rgb || defaultBgColors;
className={`relative aspect-video rounded-lg overflow-hidden
${currentSketchIndex === index ? 'ring-2 ring-blue-500 z-10' : 'hover:ring-2 hover:ring-blue-500/50'}`} return (
onClick={() => !isDragging && onSketchSelect(index)} <div
> key={`video-${index}`}
<ProgressiveReveal className={`relative aspect-video rounded-lg overflow-hidden
{...presets.thumbnail} ${currentSketchIndex === index ? 'ring-2 ring-blue-500 z-10' : 'hover:ring-2 hover:ring-blue-500/50'}`}
delay={index * 0.1} onClick={() => !isDragging && onSketchSelect(index)}
customVariants={{
hidden: {
opacity: 0,
scale: 0.95,
filter: "blur(10px)"
},
visible: {
opacity: 1,
scale: 1,
filter: "blur(0px)",
transition: {
duration: 0.8,
ease: [0.23, 1, 0.32, 1],
opacity: { duration: 0.6, ease: "easeInOut" },
scale: { duration: 1, ease: "easeOut" },
filter: { duration: 0.8, ease: "easeOut", delay: 0.2 }
}
}
}}
loadingBgConfig={{
...presets.thumbnail.loadingBgConfig,
glowOpacity: 0.4,
duration: 4
}}
> >
{/* 底层草图,始终显示 */}
<div className="w-full h-full transform hover:scale-105 transition-transform duration-500"> <div className="w-full h-full transform hover:scale-105 transition-transform duration-500">
{taskVideos[index] ? ( <img
<video className="w-full h-full object-cover"
className="w-full h-full object-cover" src={sketch.url}
src={taskVideos[index].url} alt={`Thumbnail ${index + 1}`}
muted />
playsInline </div>
loop
poster={sketch.url} {/* 视频层只在有视频时用ProgressiveReveal动画显示 */}
/> {taskVideos[index] && (
) : ( <div className="absolute inset-0">
<img {isGeneratingVideo ? (
className="w-full h-full object-cover" <ProgressiveReveal
src={sketch.url} key={`video-thumbnail-generating-${index}`}
alt={`缩略图 ${index + 1}`} revealDuration={0.8}
/> blurDuration={0.3}
)} initialBlur={10}
delay={index === currentSketchIndex ? 0 : index * 0.1}
customVariants={{
hidden: {
opacity: 0,
scale: 0.95,
filter: "blur(10px)"
},
visible: {
opacity: 1,
scale: 1,
filter: "blur(0px)",
transition: {
duration: 0.8,
ease: [0.23, 1, 0.32, 1],
opacity: { duration: 0.6, ease: "easeInOut" },
scale: { duration: 1, ease: "easeOut" },
filter: { duration: 0.8, ease: "easeOut", delay: 0.2 }
}
}
}}
loadingBgConfig={{
fromColor: `from-[${bgColors[0]}]`,
viaColor: `via-[${bgColors[1]}]`,
toColor: `to-[${bgColors[2]}]`,
glowOpacity: 0.4,
duration: 4
}}
>
<div className="w-full h-full transform hover:scale-105 transition-transform duration-500">
<video
className="w-full h-full object-cover"
src={taskVideos[index].url}
playsInline
loop
poster={sketch.url}
/>
</div>
</ProgressiveReveal>
) : (
/* 生成完成后直接显示视频不使用ProgressiveReveal */
<div className="w-full h-full transform hover:scale-105 transition-transform duration-500">
<video
className="w-full h-full object-cover"
src={taskVideos[index].url}
playsInline
loop
poster={sketch.url}
/>
</div>
)}
</div>
)}
<div className="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/60 to-transparent z-10">
<span className="text-xs text-white/90">Scene {index + 1}</span>
</div> </div>
</ProgressiveReveal>
<div className="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/60 to-transparent">
<span className="text-xs text-white/90"> {index + 1}</span>
</div> </div>
</div> );
)) })
); );
// 渲染分镜草图阶段的缩略图 // 渲染分镜草图阶段的缩略图
const renderSketchThumbnails = () => ( const renderSketchThumbnails = () => (
<> <>
{taskSketch.map((sketch, index) => ( {taskSketch.map((sketch, index) => {
<div const defaultBgColors = ['RGB(45, 50, 70)', 'RGB(75, 80, 100)', 'RGB(105, 110, 130)'];
key={`sketch-${index}`} const bgColors = sketch?.bg_rgb || defaultBgColors;
className={`relative aspect-video rounded-lg overflow-hidden
${currentSketchIndex === index ? 'ring-2 ring-blue-500 z-10' : 'hover:ring-2 hover:ring-blue-500/50'}`} return (
onClick={() => !isDragging && onSketchSelect(index)} <div
> key={`sketch-${index}`}
<ProgressiveReveal className={`relative aspect-video rounded-lg overflow-hidden
{...presets.thumbnail} ${currentSketchIndex === index ? 'ring-2 ring-blue-500 z-10' : 'hover:ring-2 hover:ring-blue-500/50'}`}
delay={index * 0.1} onClick={() => !isDragging && onSketchSelect(index)}
customVariants={{
hidden: {
opacity: 0,
scale: 0.95,
filter: "blur(10px)"
},
visible: {
opacity: 1,
scale: 1,
filter: "blur(0px)",
transition: {
duration: 0.8,
ease: [0.23, 1, 0.32, 1], // cubic-bezier
opacity: { duration: 0.6, ease: "easeInOut" },
scale: { duration: 1, ease: "easeOut" },
filter: { duration: 0.8, ease: "easeOut", delay: 0.2 }
}
}
}}
loadingBgConfig={{
...presets.thumbnail.loadingBgConfig,
glowOpacity: 0.4,
duration: 4
}}
> >
<div className="w-full h-full transform hover:scale-105 transition-transform duration-500"> {/* 只在生成过程中或没有分镜图片时使用ProgressiveReveal */}
<img {(isGeneratingSketch || !sketch) ? (
className="w-full h-full object-cover" <ProgressiveReveal
src={sketch.url} key={`sketch-thumbnail-generating-${index}`}
alt={`缩略图 ${index + 1}`} revealDuration={0.8}
/> blurDuration={0.3}
initialBlur={10}
delay={index === currentSketchIndex ? 0 : index * 0.1}
customVariants={{
hidden: {
opacity: 0,
scale: 0.95,
filter: "blur(10px)"
},
visible: {
opacity: 1,
scale: 1,
filter: "blur(0px)",
transition: {
duration: 0.8,
ease: [0.23, 1, 0.32, 1],
opacity: { duration: 0.6, ease: "easeInOut" },
scale: { duration: 1, ease: "easeOut" },
filter: { duration: 0.8, ease: "easeOut", delay: 0.2 }
}
}
}}
loadingBgConfig={{
fromColor: `from-[${bgColors[0]}]`,
viaColor: `via-[${bgColors[1]}]`,
toColor: `to-[${bgColors[2]}]`,
glowOpacity: 0.4,
duration: 4
}}
>
<div className="w-full h-full transform hover:scale-105 transition-transform duration-500">
<img
className="w-full h-full object-cover"
src={sketch.url}
alt={`Thumbnail ${index + 1}`}
/>
</div>
</ProgressiveReveal>
) : (
/* 生成完成后直接显示不使用ProgressiveReveal */
<div className="w-full h-full transform hover:scale-105 transition-transform duration-500">
<img
className="w-full h-full object-cover"
src={sketch.url}
alt={`Thumbnail ${index + 1}`}
/>
</div>
)}
<div className="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/60 to-transparent">
<span className="text-xs text-white/90">Scene {index + 1}</span>
</div> </div>
</ProgressiveReveal>
<div className="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/60 to-transparent">
<span className="text-xs text-white/90"> {index + 1}</span>
</div> </div>
</div> );
))} })}
{isGeneratingSketch && sketchCount < MOCK_SKETCH_COUNT && renderGeneratingThumbnail()} {isGeneratingSketch && sketchCount < totalSketchCount && renderGeneratingThumbnail()}
</> </>
); );
return ( return (
<div <div
ref={thumbnailsRef} ref={thumbnailsRef}
className="w-full grid grid-flow-col auto-cols-[20%] gap-4 overflow-x-auto hidden-scrollbar px-1 py-1 cursor-grab active:cursor-grabbing" className="w-full grid grid-flow-col auto-cols-[20%] gap-4 overflow-x-auto hide-scrollbar px-1 py-1 cursor-grab active:cursor-grabbing"
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove} onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp} onMouseUp={handleMouseUp}

View File

@ -19,23 +19,23 @@ export function usePlaybackControls(taskSketch: any[], taskVideos: any[], curren
setIsVideoPlaying(prev => !prev); setIsVideoPlaying(prev => !prev);
}, []); }, []);
// 自动播放逻辑 - 分镜草图 // 自动播放逻辑 - 分镜草图(移除重复的定时器逻辑,由主组件处理)
useEffect(() => { // useEffect(() => {
if (isPlaying && taskSketch.length > 0) { // if (isPlaying && taskSketch.length > 0) {
playTimerRef.current = setInterval(() => { // playTimerRef.current = setInterval(() => {
// 这里的切换逻辑需要在父组件中处理 // // 这里的切换逻辑需要在父组件中处理
// 因为需要访问 setCurrentSketchIndex // // 因为需要访问 setCurrentSketchIndex
}, 2000); // }, 1000);
} else if (playTimerRef.current) { // } else if (playTimerRef.current) {
clearInterval(playTimerRef.current); // clearInterval(playTimerRef.current);
} // }
return () => { // return () => {
if (playTimerRef.current) { // if (playTimerRef.current) {
clearInterval(playTimerRef.current); // clearInterval(playTimerRef.current);
} // }
}; // };
}, [isPlaying, taskSketch.length]); // }, [isPlaying, taskSketch.length]);
// 视频自动播放逻辑 // 视频自动播放逻辑
useEffect(() => { useEffect(() => {
@ -55,12 +55,13 @@ export function usePlaybackControls(taskSketch: any[], taskVideos: any[], curren
}; };
}, [isVideoPlaying, taskVideos.length]); }, [isVideoPlaying, taskVideos.length]);
// 当切换到视频模式时,停止播放 // 当切换到视频模式时,停止分镜草图播放(注释掉,让用户手动控制)
useEffect(() => { // useEffect(() => {
if (currentStep === '3') { // if (Number(currentStep) >= 3) {
setIsPlaying(false); // console.log('切换到步骤3+,停止分镜草图播放');
} // setIsPlaying(false);
}, [currentStep]); // }
// }, [currentStep]);
// 当切换到分镜草图模式时,停止视频播放 // 当切换到分镜草图模式时,停止视频播放
useEffect(() => { useEffect(() => {
@ -74,6 +75,7 @@ export function usePlaybackControls(taskSketch: any[], taskVideos: any[], curren
isVideoPlaying, isVideoPlaying,
showControls, showControls,
setShowControls, setShowControls,
setIsPlaying,
togglePlay, togglePlay,
toggleVideoPlay, toggleVideoPlay,
playTimerRef, // 暴露给父组件使用 playTimerRef, // 暴露给父组件使用

View File

@ -1,29 +1,10 @@
'use client'; 'use client';
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { getRandomMockData, STEP_MESSAGES } from '@/components/work-flow/constants';
const MOCK_SKETCH_URLS = [ // 当前选择的mock数据
'https://d3phaj0sisr2ct.cloudfront.net/app/gen4/object-reference/welcome-ref-1.jpg', let selectedMockData = getRandomMockData();
'https://d3phaj0sisr2ct.cloudfront.net/app/gen4/object-reference/welcome-ref-2.jpg',
'https://d3phaj0sisr2ct.cloudfront.net/app/gen4/object-reference/welcome-ref-3.jpg',
'https://d3phaj0sisr2ct.cloudfront.net/app/gen4/object-reference/welcome-ref-4.jpg',
];
const MOCK_SKETCH_SCRIPT = [
'script-123',
'script-123',
'script-123',
'script-123',
];
const MOCK_VIDEO_URLS = [
'https://cdn.qikongjian.com/videos/1750385931_99a8fb42-af89-4ae9-841a-a49869f026bd_text_to_video_0.mp4',
'https://cdn.qikongjian.com/videos/1750389908_37d4fffa-8516-43a3-a423-fc0274f40e8a_text_to_video_0.mp4',
'https://cdn.qikongjian.com/videos/1750384661_d8e30b79-828e-48cd-9025-ab62a996717c_text_to_video_0.mp4',
'https://cdn.qikongjian.com/videos/1750320040_4b47996e-7c70-490e-8433-80c7df990fdd_text_to_video_0.mp4',
];
const MOCK_SKETCH_COUNT = 8;
export function useWorkflowData() { export function useWorkflowData() {
const [taskObject, setTaskObject] = useState<any>(null); const [taskObject, setTaskObject] = useState<any>(null);
@ -35,20 +16,23 @@ export function useWorkflowData() {
const [currentSketchIndex, setCurrentSketchIndex] = useState(0); const [currentSketchIndex, setCurrentSketchIndex] = useState(0);
const [isGeneratingSketch, setIsGeneratingSketch] = useState(false); const [isGeneratingSketch, setIsGeneratingSketch] = useState(false);
const [isGeneratingVideo, setIsGeneratingVideo] = useState(false); const [isGeneratingVideo, setIsGeneratingVideo] = useState(false);
const [currentLoadingText, setCurrentLoadingText] = useState('加载中...'); const [currentLoadingText, setCurrentLoadingText] = useState('Loading task information...');
// 模拟接口请求 获取任务详情 // 模拟接口请求 获取任务详情
const getTaskDetail = async (taskId: string) => { const getTaskDetail = async (taskId: string) => {
// 每次进入页面时重新随机选择数据
selectedMockData = getRandomMockData();
const data = { const data = {
projectId: 'projectId-123', projectId: selectedMockData.detail.projectId,
projectName: "Project 1", projectName: selectedMockData.detail.projectName,
taskId: taskId, taskId: taskId,
taskName: "Task 1", taskName: selectedMockData.detail.taskName,
taskDescription: "Task 1 Description", taskDescription: selectedMockData.detail.taskDescription,
taskStatus: "1", taskStatus: selectedMockData.detail.taskStatus,
taskProgress: 0, taskProgress: 0,
mode: 'auto', mode: selectedMockData.detail.mode,
resolution: '1080p', resolution: selectedMockData.detail.resolution.toString(),
}; };
return data; return data;
}; };
@ -60,14 +44,18 @@ export function useWorkflowData() {
setIsGeneratingSketch(true); setIsGeneratingSketch(true);
setTaskSketch([]); setTaskSketch([]);
const sketchData = selectedMockData.sketch;
const totalSketches = sketchData.length;
// 模拟分批获取分镜草图 // 模拟分批获取分镜草图
for (let i = 0; i < MOCK_SKETCH_COUNT; i++) { for (let i = 0; i < totalSketches; i++) {
await new Promise(resolve => setTimeout(resolve, 2000)); await new Promise(resolve => setTimeout(resolve, 10000));
const newSketch = { const newSketch = {
id: `sketch-${i}`, id: `sketch-${i}`,
url: MOCK_SKETCH_URLS[i % MOCK_SKETCH_URLS.length], url: sketchData[i].url,
script: MOCK_SKETCH_SCRIPT[i % MOCK_SKETCH_SCRIPT.length], script: sketchData[i].script,
bg_rgb: sketchData[i].bg_rgb,
status: 'done' status: 'done'
}; };
@ -81,22 +69,24 @@ export function useWorkflowData() {
setSketchCount(i + 1); setSketchCount(i + 1);
} }
// 等待最后一个动画完成再设置生成状态为false
await new Promise(resolve => setTimeout(resolve, 1500));
setIsGeneratingSketch(false); setIsGeneratingSketch(false);
}; };
// 模拟接口请求 每次获取一个角色 轮询获取 // 模拟接口请求 每次获取一个角色 轮询获取
const getTaskRole = async (taskId: string) => { const getTaskRole = async (taskId: string) => {
await new Promise(resolve => setTimeout(resolve, 2000)); await new Promise(resolve => setTimeout(resolve, 30000 * selectedMockData.roles.length)); // 延长到30秒
}; };
// 模拟接口请求 获取背景音 // 模拟接口请求 获取背景音
const getTaskBackgroundAudio = async (taskId: string) => { const getTaskBackgroundAudio = async (taskId: string) => {
await new Promise(resolve => setTimeout(resolve, 2000)); await new Promise(resolve => setTimeout(resolve, 10000)); // 10s
}; };
// 模拟接口请求 获取最终成品 // 模拟接口请求 获取最终成品
const getTaskFinalProduct = async (taskId: string) => { const getTaskFinalProduct = async (taskId: string) => {
await new Promise(resolve => setTimeout(resolve, 2000)); await new Promise(resolve => setTimeout(resolve, 50000)); // 50s
}; };
// 模拟接口请求 每次获取一个分镜视频 轮询获取 // 模拟接口请求 每次获取一个分镜视频 轮询获取
@ -104,14 +94,17 @@ export function useWorkflowData() {
setIsGeneratingVideo(true); setIsGeneratingVideo(true);
setTaskVideos([]); setTaskVideos([]);
const videoData = selectedMockData.video;
const totalVideos = videoData.length;
// 模拟分批获取分镜视频 // 模拟分批获取分镜视频
for (let i = 0; i < MOCK_SKETCH_COUNT; i++) { for (let i = 0; i < totalVideos; i++) {
await new Promise(resolve => setTimeout(resolve, 5000)); await new Promise(resolve => setTimeout(resolve, 60000)); // 60s
const newVideo = { const newVideo = {
id: `video-${i}`, id: `video-${i}`,
url: MOCK_VIDEO_URLS[i % MOCK_VIDEO_URLS.length], url: videoData[i].url,
script: MOCK_SKETCH_SCRIPT[i % MOCK_SKETCH_SCRIPT.length], script: videoData[i].script,
status: 'done' status: 'done'
}; };
@ -124,36 +117,40 @@ export function useWorkflowData() {
setCurrentSketchIndex(i); setCurrentSketchIndex(i);
} }
// 等待最后一个动画完成再设置生成状态为false
await new Promise(resolve => setTimeout(resolve, 1500));
setIsGeneratingVideo(false); setIsGeneratingVideo(false);
}; };
// 更新加载文字 // 更新加载文字
useEffect(() => { useEffect(() => {
if (isLoading) { if (isLoading) {
setCurrentLoadingText('正在加载任务信息...'); setCurrentLoadingText(STEP_MESSAGES.loading);
return; return;
} }
const totalSketches = selectedMockData.sketch.length;
if (currentStep === '1') { if (currentStep === '1') {
if (isGeneratingSketch) { if (isGeneratingSketch) {
setCurrentLoadingText(`正在生成分镜草图 ${sketchCount + 1}/${MOCK_SKETCH_COUNT}...`); setCurrentLoadingText(STEP_MESSAGES.sketch(sketchCount, totalSketches));
} else { } else {
setCurrentLoadingText('分镜草图生成完成'); setCurrentLoadingText(STEP_MESSAGES.sketchComplete);
} }
} else if (currentStep === '2') { } else if (currentStep === '2') {
setCurrentLoadingText('正在绘制角色...'); setCurrentLoadingText(STEP_MESSAGES.character);
} else if (currentStep === '3') { } else if (currentStep === '3') {
if (isGeneratingVideo) { if (isGeneratingVideo) {
setCurrentLoadingText(`正在生成分镜视频 ${taskVideos.length + 1}/${taskSketch.length}...`); setCurrentLoadingText(STEP_MESSAGES.video(taskVideos.length, totalSketches));
} else { } else {
setCurrentLoadingText('分镜视频生成完成'); setCurrentLoadingText(STEP_MESSAGES.videoComplete);
} }
} else if (currentStep === '4') { } else if (currentStep === '4') {
setCurrentLoadingText('正在生成背景音...'); setCurrentLoadingText(STEP_MESSAGES.audio);
} else if (currentStep === '5') { } else if (currentStep === '5') {
setCurrentLoadingText('正在生成最终成品...'); setCurrentLoadingText(STEP_MESSAGES.final);
} else { } else {
setCurrentLoadingText('任务完成'); setCurrentLoadingText(STEP_MESSAGES.complete);
} }
}, [isLoading, currentStep, isGeneratingSketch, sketchCount, isGeneratingVideo, taskVideos.length, taskSketch.length]); }, [isLoading, currentStep, isGeneratingSketch, sketchCount, isGeneratingVideo, taskVideos.length, taskSketch.length]);
@ -167,7 +164,6 @@ export function useWorkflowData() {
// 只在任务详情加载完成后获取分镜草图 // 只在任务详情加载完成后获取分镜草图
await getTaskSketch(taskId); await getTaskSketch(taskId);
await new Promise(resolve => setTimeout(resolve, 2000));
// 修改 taskObject 下的 taskStatus 为 '2' // 修改 taskObject 下的 taskStatus 为 '2'
setTaskObject((prev: any) => ({ setTaskObject((prev: any) => ({
@ -178,7 +174,6 @@ export function useWorkflowData() {
// 获取分镜草图后,开始绘制角色 // 获取分镜草图后,开始绘制角色
await getTaskRole(taskId); await getTaskRole(taskId);
await new Promise(resolve => setTimeout(resolve, 2000));
// 修改 taskObject 下的 taskStatus 为 '3' // 修改 taskObject 下的 taskStatus 为 '3'
setTaskObject((prev: any) => ({ setTaskObject((prev: any) => ({
@ -189,7 +184,6 @@ export function useWorkflowData() {
// 获取绘制角色后,开始获取分镜视频 // 获取绘制角色后,开始获取分镜视频
await getTaskVideo(taskId); await getTaskVideo(taskId);
await new Promise(resolve => setTimeout(resolve, 2000));
// 修改 taskObject 下的 taskStatus 为 '4' // 修改 taskObject 下的 taskStatus 为 '4'
setTaskObject((prev: any) => ({ setTaskObject((prev: any) => ({
@ -208,7 +202,15 @@ export function useWorkflowData() {
taskStatus: '5' taskStatus: '5'
})); }));
setCurrentStep('5'); setCurrentStep('5');
// 后期制作:抽卡中 对口型中 配音中 一致性处理中
setCurrentLoadingText(STEP_MESSAGES.postProduction('Selecting optimal frames'));
await new Promise(resolve => setTimeout(resolve, 10000));
setCurrentLoadingText(STEP_MESSAGES.postProduction('Aligning lip sync'));
await new Promise(resolve => setTimeout(resolve, 10000));
setCurrentLoadingText(STEP_MESSAGES.postProduction('Adding background audio'));
await new Promise(resolve => setTimeout(resolve, 10000));
setCurrentLoadingText(STEP_MESSAGES.postProduction('Consistency processing'));
await new Promise(resolve => setTimeout(resolve, 10000));
// 获取背景音后,开始获取最终成品 // 获取背景音后,开始获取最终成品
await getTaskFinalProduct(taskId); await getTaskFinalProduct(taskId);
await new Promise(resolve => setTimeout(resolve, 2000)); await new Promise(resolve => setTimeout(resolve, 2000));
@ -234,6 +236,10 @@ export function useWorkflowData() {
isGeneratingSketch, isGeneratingSketch,
isGeneratingVideo, isGeneratingVideo,
currentLoadingText, currentLoadingText,
totalSketchCount: selectedMockData.sketch.length,
roles: selectedMockData.roles,
music: selectedMockData.music,
final: selectedMockData.final,
// 操作方法 // 操作方法
setCurrentSketchIndex, setCurrentSketchIndex,
}; };

View File

@ -207,13 +207,10 @@ export function AudioVisualizer({
</div> </div>
<div> <div>
<div className="text-sm font-medium text-white"> <div className="text-sm font-medium text-white">
{title} Audio & SFX
{hasError && <span className="text-yellow-500 text-xs ml-2">(Demo)</span>}
</div> </div>
<div className="text-xs text-white/60">Audio track</div>
</div> </div>
</div> </div>
<div className="text-sm text-white/70">{volume}%</div>
</div> </div>
{/* 波形可视化 */} {/* 波形可视化 */}
@ -293,26 +290,6 @@ export function AudioVisualizer({
{formatTime(currentTime)} / {formatTime(duration)} {formatTime(currentTime)} / {formatTime(duration)}
</span> </span>
</div> </div>
{/* 音量控制 */}
<div className="flex items-center gap-2">
<Volume2 className="w-3 h-3 text-white/60" />
<input
type="range"
min="0"
max="100"
value={volume}
onChange={(e) => {
const newVolume = parseInt(e.target.value);
onVolumeChange?.(newVolume);
}}
className="w-16 h-1 bg-white/20 rounded-lg appearance-none cursor-pointer"
style={{
background: `linear-gradient(to right, #3b82f6 0%, #3b82f6 ${volume}%, rgba(255,255,255,0.2) ${volume}%, rgba(255,255,255,0.2) 100%)`
}}
/>
<span className="text-xs text-white/60 w-8 text-right">{volume}%</span>
</div>
</div> </div>
{/* 播放状态指示器 */} {/* 播放状态指示器 */}
@ -328,13 +305,6 @@ export function AudioVisualizer({
}} }}
/> />
)} )}
{/* 错误提示 */}
{hasError && (
<div className="text-xs text-yellow-500/80 text-center">
- 使
</div>
)}
</div> </div>
</motion.div> </motion.div>
); );

View File

@ -1,40 +1,30 @@
import React, { useState, useRef } from 'react'; import React, { useState, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { Upload, Library, Play, Pause, RefreshCw, Wand2 } from 'lucide-react'; import { Upload, Library, Play, Pause, RefreshCw, Wand2, Users } from 'lucide-react';
import { cn } from '@/public/lib/utils'; import { cn } from '@/public/lib/utils';
import { GlassIconButton } from './glass-icon-button'; import { GlassIconButton } from './glass-icon-button';
import { ReplaceCharacterModal } from './replace-character-modal'; import { ReplaceCharacterModal } from './replace-character-modal';
interface Role {
name: string;
url: string;
sound: string;
soundDescription: string;
roleDescription: string;
}
interface CharacterTabContentProps { interface CharacterTabContentProps {
taskSketch: any[]; taskSketch: any[];
currentSketchIndex: number; currentSketchIndex: number;
onSketchSelect: (index: number) => void; onSketchSelect: (index: number) => void;
roles: Role[];
} }
// 模拟角色数据
const MOCK_CHARACTERS = [
{
id: 1,
name: '雪 (YUKI)',
avatar: '/assets/3dr_chihiro.png',
voiceDescription: '年轻女性,温柔而坚定的声线,语速适中,带有轻微的感性色彩。',
characterDescription: '一位接近二十岁或二十出头的年轻女性,东亚裔,拥有深色长发和刘海,五官柔和且富有表现力。',
voiceUrl: 'https://example.com/voice-sample.mp3'
},
{
id: 2,
name: '春 (HARU)',
avatar: '/assets/3dr_mono.png',
voiceDescription: '年轻男性,清澈而温和的声线,语速从容,带有知性的特质。',
characterDescription: '一位接近二十岁或二十出头的年轻男性,东亚裔,拥有深色、发型整洁的中长发和深思的气质。',
voiceUrl: 'https://example.com/voice-sample.mp3'
},
];
export function CharacterTabContent({ export function CharacterTabContent({
taskSketch, taskSketch,
currentSketchIndex, currentSketchIndex,
onSketchSelect onSketchSelect,
roles = []
}: CharacterTabContentProps) { }: CharacterTabContentProps) {
const [selectedCharacterIndex, setSelectedCharacterIndex] = useState(0); const [selectedCharacterIndex, setSelectedCharacterIndex] = useState(0);
const [isReplaceModalOpen, setIsReplaceModalOpen] = useState(false); const [isReplaceModalOpen, setIsReplaceModalOpen] = useState(false);
@ -47,6 +37,16 @@ export function CharacterTabContent({
}>({ type: null, value: '' }); }>({ type: null, value: '' });
const audioRef = useRef<HTMLAudioElement>(null); const audioRef = useRef<HTMLAudioElement>(null);
// 如果没有角色数据,显示占位内容
if (!roles || roles.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>
</div>
);
}
// 处理音频播放进度 // 处理音频播放进度
const handleTimeUpdate = () => { const handleTimeUpdate = () => {
if (audioRef.current) { if (audioRef.current) {
@ -79,6 +79,9 @@ export function CharacterTabContent({
} }
}; };
// 获取当前选中的角色
const currentRole = roles[selectedCharacterIndex];
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{/* 上部分:角色缩略图 */} {/* 上部分:角色缩略图 */}
@ -89,9 +92,9 @@ export function CharacterTabContent({
> >
<div className="relative"> <div className="relative">
<div className="flex gap-4 overflow-x-auto p-2 hide-scrollbar"> <div className="flex gap-4 overflow-x-auto p-2 hide-scrollbar">
{MOCK_CHARACTERS.map((character, index) => ( {roles.map((role, index) => (
<motion.div <motion.div
key={character.id} key={`role-${index}`}
className={cn( className={cn(
'relative flex-shrink-0 w-24 rounded-lg overflow-hidden cursor-pointer', 'relative flex-shrink-0 w-24 rounded-lg overflow-hidden cursor-pointer',
'aspect-[9/16]', 'aspect-[9/16]',
@ -102,12 +105,12 @@ export function CharacterTabContent({
whileTap={{ scale: 0.95 }} whileTap={{ scale: 0.95 }}
> >
<img <img
src={character.avatar} src={role.url}
alt={character.name} alt={role.name}
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
<div className="absolute bottom-0 left-0 right-0 p-1 bg-gradient-to-t from-black/60 to-transparent"> <div className="absolute bottom-0 left-0 right-0 p-1 bg-gradient-to-t from-black/60 to-transparent">
<span className="text-xs text-white/90 line-clamp-1">{character.name}</span> <span className="text-xs text-white/90 line-clamp-1">{role.name}</span>
</div> </div>
</motion.div> </motion.div>
))} ))}
@ -122,7 +125,7 @@ export function CharacterTabContent({
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }} transition={{ delay: 0.1 }}
> >
<h3 className="text-sm font-medium mb-2"></h3> <h3 className="text-sm font-medium mb-2">Replace character</h3>
<div className="flex gap-4"> <div className="flex gap-4">
<motion.button <motion.button
className="flex-1 flex flex-col items-center gap-2 p-4 rounded-lg border border-white/10 hover:border-white/20 transition-colors" className="flex-1 flex flex-col items-center gap-2 p-4 rounded-lg border border-white/10 hover:border-white/20 transition-colors"
@ -134,7 +137,7 @@ export function CharacterTabContent({
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}
> >
<Upload className="w-6 h-6" /> <Upload className="w-6 h-6" />
<span></span> <span>Upload character</span>
</motion.button> </motion.button>
<motion.button <motion.button
@ -147,7 +150,7 @@ export function CharacterTabContent({
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}
> >
<Library className="w-6 h-6" /> <Library className="w-6 h-6" />
<span></span> <span>Character library</span>
</motion.button> </motion.button>
</div> </div>
</motion.div> </motion.div>
@ -163,10 +166,10 @@ export function CharacterTabContent({
<div className="space-y-4"> <div className="space-y-4">
{/* 角色姓名 */} {/* 角色姓名 */}
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm text-white/70"></label> <label className="text-sm text-white/70">Character name</label>
<input <input
type="text" type="text"
value={MOCK_CHARACTERS[selectedCharacterIndex].name} value={currentRole.name}
onChange={(e) => console.log('name changed:', e.target.value)} onChange={(e) => console.log('name changed:', e.target.value)}
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg
focus:outline-none focus:border-blue-500" focus:outline-none focus:border-blue-500"
@ -174,9 +177,9 @@ export function CharacterTabContent({
</div> </div>
{/* 声音描述 */} {/* 声音描述 */}
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm text-white/70"></label> <label className="text-sm text-white/70">Voice description</label>
<textarea <textarea
value={MOCK_CHARACTERS[selectedCharacterIndex].voiceDescription} value={currentRole.soundDescription}
onChange={(e) => console.log('voice description changed:', e.target.value)} onChange={(e) => console.log('voice description changed:', e.target.value)}
className="w-full h-24 px-3 py-2 bg-white/5 border border-white/10 rounded-lg className="w-full h-24 px-3 py-2 bg-white/5 border border-white/10 rounded-lg
focus:outline-none focus:border-blue-500 resize-none" focus:outline-none focus:border-blue-500 resize-none"
@ -186,10 +189,10 @@ export function CharacterTabContent({
{/* 声音预览 */} {/* 声音预览 */}
<div className="p-4 rounded-lg bg-white/5 space-y-3"> <div className="p-4 rounded-lg bg-white/5 space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm text-white/70"></span> <span className="text-sm text-white/70">Voice preview</span>
<GlassIconButton <GlassIconButton
icon={RefreshCw} icon={RefreshCw}
tooltip="重新生成声音" tooltip="Regenerate voice"
onClick={() => console.log('regenerate voice')} onClick={() => console.log('regenerate voice')}
size="sm" size="sm"
/> />
@ -198,7 +201,7 @@ export function CharacterTabContent({
<div className="relative"> <div className="relative">
<audio <audio
ref={audioRef} ref={audioRef}
src={MOCK_CHARACTERS[selectedCharacterIndex].voiceUrl} src={currentRole.sound}
onTimeUpdate={handleTimeUpdate} onTimeUpdate={handleTimeUpdate}
onEnded={() => setIsPlaying(false)} onEnded={() => setIsPlaying(false)}
/> />
@ -244,9 +247,9 @@ export function CharacterTabContent({
<div className="space-y-4"> <div className="space-y-4">
{/* 角色描述 */} {/* 角色描述 */}
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm text-white/70"></label> <label className="text-sm text-white/70">Character description</label>
<textarea <textarea
value={MOCK_CHARACTERS[selectedCharacterIndex].characterDescription} value={currentRole.roleDescription}
onChange={(e) => console.log('character description changed:', e.target.value)} onChange={(e) => console.log('character description changed:', e.target.value)}
className="w-full h-24 px-3 py-2 bg-white/5 border border-white/10 rounded-lg className="w-full h-24 px-3 py-2 bg-white/5 border border-white/10 rounded-lg
focus:outline-none focus:border-blue-500 resize-none" focus:outline-none focus:border-blue-500 resize-none"
@ -254,10 +257,10 @@ export function CharacterTabContent({
</div> </div>
{/* 角色预览 */} {/* 角色预览 */}
<div className="w-full max-w-[280px] mx-auto aspect-[9/16] rounded-lg overflow-hidden relative group"> <div className="w-full mx-auto rounded-lg overflow-hidden relative group">
<img <img
src={MOCK_CHARACTERS[selectedCharacterIndex].avatar} src={currentRole.url}
alt={MOCK_CHARACTERS[selectedCharacterIndex].name} alt={currentRole.name}
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent <div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent
@ -265,7 +268,7 @@ export function CharacterTabContent({
<div className="absolute bottom-4 left-4"> <div className="absolute bottom-4 left-4">
<GlassIconButton <GlassIconButton
icon={Wand2} icon={Wand2}
tooltip="重新生成角色形象" tooltip="Regenerate character"
onClick={() => console.log('regenerate character')} onClick={() => console.log('regenerate character')}
/> />
</div> </div>

View File

@ -19,15 +19,17 @@ interface EditModalProps {
sketchVideo: any[]; sketchVideo: any[];
currentSketchIndex: number; currentSketchIndex: number;
onSketchSelect: (index: number) => void; onSketchSelect: (index: number) => void;
roles?: any[];
music?: any;
} }
const tabs = [ const tabs = [
{ id: '1', label: '脚本', icon: FileText }, { id: '1', label: 'Script', icon: FileText },
{ id: '2', label: '角色', icon: Users }, { id: '2', label: 'Character', icon: Users },
{ id: '3', label: '分镜视频', icon: Video }, { id: '3', label: 'Sketch video', icon: Video },
{ id: '4', label: '音乐', icon: Music }, { id: '4', label: 'Music', icon: Music },
// { id: '5', label: '剪辑', icon: Scissors }, // { id: '5', label: '剪辑', icon: Scissors },
{ id: 'settings', label: '设置', icon: Settings }, { id: 'settings', label: 'Settings', icon: Settings },
]; ];
export function EditModal({ export function EditModal({
@ -38,15 +40,22 @@ export function EditModal({
taskSketch, taskSketch,
sketchVideo, sketchVideo,
currentSketchIndex, currentSketchIndex,
onSketchSelect onSketchSelect,
roles = [],
music
}: EditModalProps) { }: EditModalProps) {
const [activeTab, setActiveTab] = useState(activeEditTab); const [activeTab, setActiveTab] = useState(activeEditTab);
const [currentIndex, setCurrentIndex] = useState(currentSketchIndex);
// 当 activeEditTab 改变时更新 activeTab // 当 activeEditTab 改变时更新 activeTab
useEffect(() => { useEffect(() => {
setActiveTab(activeEditTab); setActiveTab(activeEditTab);
}, [activeEditTab]); }, [activeEditTab]);
useEffect(() => {
setCurrentIndex(currentSketchIndex);
}, [isOpen]);
const isTabDisabled = (tabId: string) => { const isTabDisabled = (tabId: string) => {
if (tabId === 'settings') return false; if (tabId === 'settings') return false;
return parseInt(tabId) > parseInt(taskStatus); return parseInt(tabId) > parseInt(taskStatus);
@ -58,7 +67,7 @@ export function EditModal({
return ( return (
<ScriptTabContent <ScriptTabContent
taskSketch={taskSketch} taskSketch={taskSketch}
currentSketchIndex={currentSketchIndex} currentSketchIndex={currentIndex}
onSketchSelect={onSketchSelect} onSketchSelect={onSketchSelect}
/> />
); );
@ -66,15 +75,16 @@ export function EditModal({
return ( return (
<CharacterTabContent <CharacterTabContent
taskSketch={taskSketch} taskSketch={taskSketch}
currentSketchIndex={currentSketchIndex} currentSketchIndex={currentIndex}
onSketchSelect={onSketchSelect} onSketchSelect={onSketchSelect}
roles={roles}
/> />
); );
case '3': case '3':
return ( return (
<VideoTabContent <VideoTabContent
taskSketch={sketchVideo} taskSketch={sketchVideo}
currentSketchIndex={currentSketchIndex} currentSketchIndex={currentIndex}
onSketchSelect={onSketchSelect} onSketchSelect={onSketchSelect}
isPlaying={false} isPlaying={false}
/> />
@ -83,8 +93,9 @@ export function EditModal({
return ( return (
<MusicTabContent <MusicTabContent
taskSketch={taskSketch} taskSketch={taskSketch}
currentSketchIndex={currentSketchIndex} currentSketchIndex={currentIndex}
onSketchSelect={onSketchSelect} onSketchSelect={onSketchSelect}
music={music}
/> />
); );
case 'settings': case 'settings':
@ -99,7 +110,7 @@ export function EditModal({
default: default:
return ( return (
<div className="min-h-[400px] flex items-center justify-center text-white/50"> <div className="min-h-[400px] flex items-center justify-center text-white/50">
{tabs.find(tab => tab.id === activeTab)?.label} {tabs.find(tab => tab.id === activeTab)?.label} Content area
</div> </div>
); );
} }
@ -200,14 +211,14 @@ export function EditModal({
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}
onClick={onClose} onClick={onClose}
> >
Reset
</motion.button> </motion.button>
<motion.button <motion.button
className="px-4 py-2 rounded-lg bg-blue-500 text-white hover:bg-blue-600 transition-colors" className="px-4 py-2 rounded-lg bg-blue-500 text-white hover:bg-blue-600 transition-colors"
whileHover={{ scale: 1.02 }} whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}
> >
Save
</motion.button> </motion.button>
</div> </div>
</div> </div>

View File

@ -0,0 +1,90 @@
'use client';
import React from 'react';
import { AlertTriangle, RefreshCw } from 'lucide-react';
import { motion } from 'framer-motion';
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
interface ErrorBoundaryProps {
children: React.ReactNode;
fallback?: React.ComponentType<{ error: Error; resetError: () => void }>;
}
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('Error caught by boundary:', error, errorInfo);
}
resetError = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError) {
if (this.props.fallback) {
const FallbackComponent = this.props.fallback;
return <FallbackComponent error={this.state.error!} resetError={this.resetError} />;
}
return (
<motion.div
className="flex flex-col items-center justify-center min-h-[400px] p-6 bg-red-500/10 border border-red-500/20 rounded-lg"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3 }}
>
<AlertTriangle className="w-12 h-12 text-red-500 mb-4" />
<h3 className="text-lg font-semibold text-red-400 mb-2"></h3>
<p className="text-white/70 text-center mb-4 max-w-md">
{this.state.error?.message || '组件渲染时发生错误,请尝试刷新页面或重置组件'}
</p>
<motion.button
className="flex items-center gap-2 px-4 py-2 bg-red-500/20 text-red-400 border border-red-500/30 rounded-lg hover:bg-red-500/30 transition-colors"
onClick={this.resetError}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<RefreshCw className="w-4 h-4" />
</motion.button>
</motion.div>
);
}
return this.props.children;
}
}
// Hook 版本的错误边界
export function useErrorBoundary() {
const [error, setError] = React.useState<Error | null>(null);
const resetError = React.useCallback(() => {
setError(null);
}, []);
const captureError = React.useCallback((error: Error) => {
setError(error);
}, []);
React.useEffect(() => {
if (error) {
throw error;
}
}, [error]);
return { captureError, resetError };
}

View File

@ -64,7 +64,7 @@ export function GenerateVideoModal({
</div> </div>
{/* 主要内容区域 */} {/* 主要内容区域 */}
<div className="p-6 space-y-6 h-[80vh] flex flex-col overflow-y-auto hidden-scrollbar"> <div className="p-6 space-y-6 h-[80vh] flex flex-col overflow-y-auto hide-scrollbar">
{/* 文本输入区域 */} {/* 文本输入区域 */}
<div className="space-y-2 flex-shrink-0"> <div className="space-y-2 flex-shrink-0">
<label className="text-sm text-white/70"></label> <label className="text-sm text-white/70"></label>

View File

@ -170,7 +170,7 @@ export function MediaPropertiesModal({
onMouseLeave={(e) => e.currentTarget.pause()} onMouseLeave={(e) => e.currentTarget.pause()}
/> />
<div className="absolute bottom-0 left-0 right-0 p-1 bg-gradient-to-t from-black/60 to-transparent"> <div className="absolute bottom-0 left-0 right-0 p-1 bg-gradient-to-t from-black/60 to-transparent">
<span className="text-xs text-white/90"> {index + 1}</span> <span className="text-xs text-white/90">Scene {index + 1}</span>
</div> </div>
</motion.div> </motion.div>
))} ))}
@ -314,10 +314,10 @@ export function MediaPropertiesModal({
}} }}
> >
<option value="Auto">Auto</option> <option value="Auto">Auto</option>
<option value="淡入淡出"></option> <option value="Fade">Fade</option>
<option value="滑动"></option> <option value="Slide">Slide</option>
<option value="缩放"></option> <option value="Zoom">Zoom</option>
<option value="旋转"></option> <option value="Rotate">Rotate</option>
</select> </select>
</div> </div>

View File

@ -1,44 +1,67 @@
'use client'; 'use client';
import React, { useState, useRef } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { Upload, Library, Play, Pause, RefreshCw, Music2, Volume2 } from 'lucide-react'; import { Upload, Library, Play, Pause, RefreshCw, Music2, Volume2 } from 'lucide-react';
import { cn } from '@/public/lib/utils'; import { cn } from '@/public/lib/utils';
import { GlassIconButton } from './glass-icon-button'; import { GlassIconButton } from './glass-icon-button';
import { ReplaceMusicModal } from './replace-music-modal'; import { ReplaceMusicModal } from './replace-music-modal';
interface Music {
url: string;
script: string;
name?: string;
duration?: string;
totalDuration?: string;
isLooped?: boolean;
}
interface MusicTabContentProps { interface MusicTabContentProps {
taskSketch: any[]; taskSketch: any[];
currentSketchIndex: number; currentSketchIndex: number;
onSketchSelect: (index: number) => void; onSketchSelect: (index: number) => void;
music?: Music;
} }
// 模拟音乐数据
const MOCK_MUSICS = [
{
id: 1,
name: 'Music 1 / Rocka',
duration: '00m : 21s : 000ms',
totalDuration: '01m : 35s : 765ms',
isLooped: true,
url: 'https://example.com/music1.mp3'
}
];
export function MusicTabContent({ export function MusicTabContent({
taskSketch, taskSketch,
currentSketchIndex, currentSketchIndex,
onSketchSelect onSketchSelect,
music
}: MusicTabContentProps) { }: MusicTabContentProps) {
const [selectedMusicIndex, setSelectedMusicIndex] = useState(0);
const [isReplaceModalOpen, setIsReplaceModalOpen] = useState(false); const [isReplaceModalOpen, setIsReplaceModalOpen] = useState(false);
const [activeMethod, setActiveMethod] = useState('upload'); const [activeMethod, setActiveMethod] = useState('upload');
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
const [volume, setVolume] = useState(75); const [volume, setVolume] = useState(75);
const [isLooped, setIsLooped] = useState(music?.isLooped ?? true);
const [fadeIn, setFadeIn] = useState('0s');
const [fadeOut, setFadeOut] = useState('3s');
const [trimFrom, setTrimFrom] = useState('00 : 00');
const [trimTo, setTrimTo] = useState(music?.duration?.split(' : ').slice(0, 2).join(' : ') || '01 : 35');
const audioRef = useRef<HTMLAudioElement>(null); const audioRef = useRef<HTMLAudioElement>(null);
// 处理音频播放进度 useEffect(() => {
if (music) {
setIsLooped(music.isLooped ?? true);
if (music.duration) {
const durationParts = music.duration.split(' : ');
if (durationParts.length >= 2) {
setTrimTo(`${durationParts[0]} : ${durationParts[1]}`);
}
}
}
}, [music]);
if (!music || !music.url) {
return (
<div className="flex flex-col items-center justify-center min-h-[400px] text-white/50">
<Music2 className="w-16 h-16 mb-4" />
<p>No music data</p>
</div>
);
}
const handleTimeUpdate = () => { const handleTimeUpdate = () => {
if (audioRef.current) { if (audioRef.current) {
const progress = (audioRef.current.currentTime / audioRef.current.duration) * 100; const progress = (audioRef.current.currentTime / audioRef.current.duration) * 100;
@ -46,7 +69,6 @@ export function MusicTabContent({
} }
}; };
// 处理播放/暂停
const togglePlay = () => { const togglePlay = () => {
if (audioRef.current) { if (audioRef.current) {
if (isPlaying) { if (isPlaying) {
@ -58,7 +80,6 @@ export function MusicTabContent({
} }
}; };
// 处理进度条点击
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => { const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (audioRef.current) { if (audioRef.current) {
const rect = e.currentTarget.getBoundingClientRect(); const rect = e.currentTarget.getBoundingClientRect();
@ -70,7 +91,6 @@ export function MusicTabContent({
} }
}; };
// 处理音量变化
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newVolume = parseInt(e.target.value); const newVolume = parseInt(e.target.value);
setVolume(newVolume); setVolume(newVolume);
@ -81,45 +101,36 @@ export function MusicTabContent({
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{/* 上部分:音乐列表 */}
<motion.div <motion.div
className="space-y-4" className="space-y-4"
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
> >
{MOCK_MUSICS.map((music, index) => ( <motion.div
<motion.div className="group relative p-4 w-[280px] rounded-lg overflow-hidden border border-blue-500 bg-blue-500/10"
key={music.id} whileHover={{ scale: 1.01 }}
className={cn( whileTap={{ scale: 0.99 }}
'group relative p-4 rounded-lg overflow-hidden cursor-pointer transition-colors w-[272px]', >
selectedMusicIndex === index ? 'border border-blue-500 bg-blue-500/10' : '' <div className="flex items-center gap-4">
)} <div className="flex-shrink-0">
onClick={() => setSelectedMusicIndex(index)} <Music2 className="w-8 h-8 text-white/70" />
whileHover={{ scale: 1.01 }} </div>
whileTap={{ scale: 0.99 }} <div className="flex-1 min-w-0">
> <div className="flex items-center justify-between">
<div className="flex items-center gap-4"> <h3 className="text-sm font-medium truncate">{music.name || music.script || 'Background music'}</h3>
<div className="flex-shrink-0">
<Music2 className="w-8 h-8 text-white/70" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium truncate">{music.name}</h3>
</div>
</div> </div>
</div> </div>
</motion.div> </div>
))} </motion.div>
</motion.div> </motion.div>
{/* 中间部分:替换区 */}
<motion.div <motion.div
className="p-4 rounded-lg bg-white/5" className="p-4 rounded-lg bg-white/5"
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }} transition={{ delay: 0.1 }}
> >
<h3 className="text-sm font-medium mb-2"></h3> <h3 className="text-sm font-medium mb-2">Replace music</h3>
<div className="flex gap-4"> <div className="flex gap-4">
<motion.button <motion.button
className="flex-1 flex flex-col items-center gap-2 p-4 rounded-lg border border-white/10 hover:border-white/20 transition-colors" className="flex-1 flex flex-col items-center gap-2 p-4 rounded-lg border border-white/10 hover:border-white/20 transition-colors"
@ -128,7 +139,7 @@ export function MusicTabContent({
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}
> >
<Upload className="w-6 h-6" /> <Upload className="w-6 h-6" />
<span></span> <span>Upload music</span>
</motion.button> </motion.button>
<motion.button <motion.button
@ -138,21 +149,18 @@ export function MusicTabContent({
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}
> >
<Library className="w-6 h-6" /> <Library className="w-6 h-6" />
<span></span> <span>Music library</span>
</motion.button> </motion.button>
</div> </div>
</motion.div> </motion.div>
{/* 下部分:音乐属性 */}
<motion.div <motion.div
className="grid grid-cols-2 gap-6" className="grid grid-cols-2 gap-6"
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }} transition={{ delay: 0.2 }}
> >
{/* 左列:音乐编辑选项 */}
<div className="space-y-6"> <div className="space-y-6">
{/* Loop Music */}
<motion.div <motion.div
className="space-y-2" className="space-y-2"
whileHover={{ scale: 1.01 }} whileHover={{ scale: 1.01 }}
@ -162,16 +170,16 @@ export function MusicTabContent({
<motion.div <motion.div
className={cn( className={cn(
"w-12 h-6 rounded-full p-1 cursor-pointer", "w-12 h-6 rounded-full p-1 cursor-pointer",
MOCK_MUSICS[selectedMusicIndex].isLooped ? "bg-blue-500" : "bg-white/10" isLooped ? "bg-blue-500" : "bg-white/10"
)} )}
onClick={() => console.log('loop toggled')} onClick={() => setIsLooped(!isLooped)}
layout layout
> >
<motion.div <motion.div
className="w-4 h-4 bg-white rounded-full" className="w-4 h-4 bg-white rounded-full"
layout layout
animate={{ animate={{
x: MOCK_MUSICS[selectedMusicIndex].isLooped ? "100%" : "0%" x: isLooped ? "100%" : "0%"
}} }}
transition={{ type: "spring", stiffness: 300, damping: 25 }} transition={{ type: "spring", stiffness: 300, damping: 25 }}
/> />
@ -179,7 +187,6 @@ export function MusicTabContent({
</div> </div>
</motion.div> </motion.div>
{/* Trim */}
<motion.div <motion.div
className="space-y-2" className="space-y-2"
whileHover={{ scale: 1.01 }} whileHover={{ scale: 1.01 }}
@ -190,7 +197,8 @@ export function MusicTabContent({
<span className="text-sm text-white/50">from</span> <span className="text-sm text-white/50">from</span>
<input <input
type="text" type="text"
value="00 : 00" value={trimFrom}
onChange={(e) => setTrimFrom(e.target.value)}
className="w-20 px-2 py-1 bg-white/5 border border-white/10 rounded text-center className="w-20 px-2 py-1 bg-white/5 border border-white/10 rounded text-center
focus:outline-none focus:border-blue-500" focus:outline-none focus:border-blue-500"
/> />
@ -199,7 +207,8 @@ export function MusicTabContent({
<span className="text-sm text-white/50">to</span> <span className="text-sm text-white/50">to</span>
<input <input
type="text" type="text"
value="01 : 35" value={trimTo}
onChange={(e) => setTrimTo(e.target.value)}
className="w-20 px-2 py-1 bg-white/5 border border-white/10 rounded text-center className="w-20 px-2 py-1 bg-white/5 border border-white/10 rounded text-center
focus:outline-none focus:border-blue-500" focus:outline-none focus:border-blue-500"
/> />
@ -207,7 +216,6 @@ export function MusicTabContent({
</div> </div>
</motion.div> </motion.div>
{/* Fade in & out */}
<motion.div <motion.div
className="space-y-4" className="space-y-4"
whileHover={{ scale: 1.01 }} whileHover={{ scale: 1.01 }}
@ -218,7 +226,8 @@ export function MusicTabContent({
<span className="text-xs text-white/50">Fade in:</span> <span className="text-xs text-white/50">Fade in:</span>
<input <input
type="text" type="text"
value="0s" value={fadeIn}
onChange={(e) => setFadeIn(e.target.value)}
className="w-full px-2 py-1 bg-white/5 border border-white/10 rounded text-center className="w-full px-2 py-1 bg-white/5 border border-white/10 rounded text-center
focus:outline-none focus:border-blue-500" focus:outline-none focus:border-blue-500"
/> />
@ -227,7 +236,8 @@ export function MusicTabContent({
<span className="text-xs text-white/50">Fade out:</span> <span className="text-xs text-white/50">Fade out:</span>
<input <input
type="text" type="text"
value="3s" value={fadeOut}
onChange={(e) => setFadeOut(e.target.value)}
className="w-full px-2 py-1 bg-white/5 border border-white/10 rounded text-center className="w-full px-2 py-1 bg-white/5 border border-white/10 rounded text-center
focus:outline-none focus:border-blue-500" focus:outline-none focus:border-blue-500"
/> />
@ -235,7 +245,6 @@ export function MusicTabContent({
</div> </div>
</motion.div> </motion.div>
{/* Music volume */}
<motion.div <motion.div
className="space-y-2" className="space-y-2"
whileHover={{ scale: 1.01 }} whileHover={{ scale: 1.01 }}
@ -263,7 +272,6 @@ export function MusicTabContent({
</motion.div> </motion.div>
</div> </div>
{/* 右列:音频预览 */}
<motion.div <motion.div
className="p-6 rounded-lg bg-white/5 flex flex-col items-center justify-center gap-6" className="p-6 rounded-lg bg-white/5 flex flex-col items-center justify-center gap-6"
whileHover={{ scale: 1.01 }} whileHover={{ scale: 1.01 }}
@ -307,10 +315,18 @@ export function MusicTabContent({
) : '0:00 / 0:00'} ) : '0:00 / 0:00'}
</div> </div>
</div> </div>
<audio
ref={audioRef}
src={music.url}
onTimeUpdate={handleTimeUpdate}
onEnded={() => setIsPlaying(false)}
loop={isLooped}
style={{ display: 'none' }}
/>
</motion.div> </motion.div>
</motion.div> </motion.div>
{/* 替换音乐弹窗 */}
<ReplaceMusicModal <ReplaceMusicModal
isOpen={isReplaceModalOpen} isOpen={isReplaceModalOpen}
activeReplaceMethod={activeMethod} activeReplaceMethod={activeMethod}

View File

@ -37,12 +37,6 @@ export function ReplaceCharacterModal({
id: 2, id: 2,
avatar: '/assets/3dr_mono.png', avatar: '/assets/3dr_mono.png',
name: '春 (HARU)', name: '春 (HARU)',
style: '写实风格'
},
{
id: 3,
avatar: '/assets/3dr_chihiro.png',
name: '夏 (NATSU)',
style: '二次元风格' style: '二次元风格'
}, },
]; ];
@ -90,7 +84,7 @@ export function ReplaceCharacterModal({
> >
<ChevronDown className="w-5 h-5" /> <ChevronDown className="w-5 h-5" />
</button> </button>
<h2 className="text-lg font-medium"></h2> <h2 className="text-lg font-medium">Replace character</h2>
</div> </div>
</div> </div>
@ -110,7 +104,7 @@ export function ReplaceCharacterModal({
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}
> >
<Upload className="w-6 h-6" /> <Upload className="w-6 h-6" />
<span></span> <span>Upload character</span>
</motion.button> </motion.button>
<motion.button <motion.button
@ -125,7 +119,7 @@ export function ReplaceCharacterModal({
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}
> >
<Library className="w-6 h-6" /> <Library className="w-6 h-6" />
<span></span> <span>Library</span>
</motion.button> </motion.button>
</div> </div>
@ -158,8 +152,8 @@ export function ReplaceCharacterModal({
<Image className="w-8 h-8 text-white/70" /> <Image className="w-8 h-8 text-white/70" />
</motion.div> </motion.div>
<div className="text-center"> <div className="text-center">
<p className="text-white/70"></p> <p className="text-white/70">Click to upload or drag and drop character images here</p>
<p className="text-sm text-white/50 mt-2"> PNG, JPG, WEBP </p> <p className="text-sm text-white/50 mt-2">Supports PNG, JPG, WEBP formats</p>
</div> </div>
</label> </label>
</motion.div> </motion.div>
@ -178,7 +172,7 @@ export function ReplaceCharacterModal({
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-white/50" /> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-white/50" />
<input <input
type="text" type="text"
placeholder="搜索角色..." placeholder="Search character..."
className="w-full pl-10 pr-4 py-2 bg-white/5 border border-white/10 rounded-lg className="w-full pl-10 pr-4 py-2 bg-white/5 border border-white/10 rounded-lg
focus:outline-none focus:border-blue-500 transition-colors" focus:outline-none focus:border-blue-500 transition-colors"
value={searchQuery} value={searchQuery}
@ -230,7 +224,7 @@ export function ReplaceCharacterModal({
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}
> >
<Plus className="w-5 h-5" /> <Plus className="w-5 h-5" />
<span></span> <span>Generate new character</span>
</motion.button> </motion.button>
</motion.div> </motion.div>
)} )}
@ -246,14 +240,14 @@ export function ReplaceCharacterModal({
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}
onClick={onClose} onClick={onClose}
> >
Reset
</motion.button> </motion.button>
<motion.button <motion.button
className="px-4 py-2 rounded-lg bg-blue-500 text-white hover:bg-blue-600 transition-colors" className="px-4 py-2 rounded-lg bg-blue-500 text-white hover:bg-blue-600 transition-colors"
whileHover={{ scale: 1.02 }} whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}
> >
Apply
</motion.button> </motion.button>
</div> </div>
</div> </div>

View File

@ -93,7 +93,7 @@ export function ReplaceMusicModal({
> >
<ChevronDown className="w-5 h-5" /> <ChevronDown className="w-5 h-5" />
</button> </button>
<h2 className="text-lg font-medium"></h2> <h2 className="text-lg font-medium">Replace music</h2>
</div> </div>
</div> </div>
@ -113,7 +113,7 @@ export function ReplaceMusicModal({
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}
> >
<Upload className="w-6 h-6" /> <Upload className="w-6 h-6" />
<span></span> <span>Upload music</span>
</motion.button> </motion.button>
<motion.button <motion.button
@ -128,7 +128,7 @@ export function ReplaceMusicModal({
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}
> >
<Library className="w-6 h-6" /> <Library className="w-6 h-6" />
<span></span> <span>Library</span>
</motion.button> </motion.button>
</div> </div>
@ -161,8 +161,8 @@ export function ReplaceMusicModal({
<Music2 className="w-8 h-8 text-white/70" /> <Music2 className="w-8 h-8 text-white/70" />
</motion.div> </motion.div>
<div className="text-center"> <div className="text-center">
<p className="text-white/70"></p> <p className="text-white/70">Click to upload or drag and drop music files here</p>
<p className="text-sm text-white/50 mt-2"> MP3, WAV, M4A </p> <p className="text-sm text-white/50 mt-2">Supports MP3, WAV, M4A formats</p>
</div> </div>
</label> </label>
</motion.div> </motion.div>
@ -181,7 +181,7 @@ export function ReplaceMusicModal({
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-white/50" /> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-white/50" />
<input <input
type="text" type="text"
placeholder="搜索音乐..." placeholder="Search music..."
className="w-full pl-10 pr-4 py-2 bg-white/5 border border-white/10 rounded-lg className="w-full pl-10 pr-4 py-2 bg-white/5 border border-white/10 rounded-lg
focus:outline-none focus:border-blue-500 transition-colors" focus:outline-none focus:border-blue-500 transition-colors"
value={searchQuery} value={searchQuery}
@ -229,14 +229,14 @@ export function ReplaceMusicModal({
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}
onClick={onClose} onClick={onClose}
> >
Reset
</motion.button> </motion.button>
<motion.button <motion.button
className="px-4 py-2 rounded-lg bg-blue-500 text-white hover:bg-blue-600 transition-colors" className="px-4 py-2 rounded-lg bg-blue-500 text-white hover:bg-blue-600 transition-colors"
whileHover={{ scale: 1.02 }} whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}
> >
Apply
</motion.button> </motion.button>
</div> </div>
</div> </div>

View File

@ -77,7 +77,7 @@ export function ReplaceVideoModal({
> >
<ChevronDown className="w-5 h-5" /> <ChevronDown className="w-5 h-5" />
</button> </button>
<h2 className="text-lg font-medium"></h2> <h2 className="text-lg font-medium">Video replacement</h2>
</div> </div>
</div> </div>
@ -97,7 +97,7 @@ export function ReplaceVideoModal({
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}
> >
<Upload className="w-6 h-6" /> <Upload className="w-6 h-6" />
<span></span> <span>Upload video</span>
</motion.button> </motion.button>
<motion.button <motion.button
@ -112,7 +112,7 @@ export function ReplaceVideoModal({
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}
> >
<Library className="w-6 h-6" /> <Library className="w-6 h-6" />
<span></span> <span>Library</span>
</motion.button> </motion.button>
<motion.button <motion.button
@ -127,7 +127,7 @@ export function ReplaceVideoModal({
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}
> >
<Wand2 className="w-6 h-6" /> <Wand2 className="w-6 h-6" />
<span></span> <span>Generate video</span>
</motion.button> </motion.button>
</div> </div>
@ -160,8 +160,8 @@ export function ReplaceVideoModal({
<FileVideo className="w-8 h-8 text-white/70" /> <FileVideo className="w-8 h-8 text-white/70" />
</motion.div> </motion.div>
<div className="text-center"> <div className="text-center">
<p className="text-white/70"></p> <p className="text-white/70">Click to upload or drag and drop video files here</p>
<p className="text-sm text-white/50 mt-2"> MP4, MOV, AVI </p> <p className="text-sm text-white/50 mt-2">Supports MP4, MOV, AVI formats</p>
</div> </div>
</label> </label>
</motion.div> </motion.div>
@ -180,7 +180,7 @@ export function ReplaceVideoModal({
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-white/50" /> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-white/50" />
<input <input
type="text" type="text"
placeholder="搜索视频..." placeholder="Search video..."
className="w-full pl-10 pr-4 py-2 bg-white/5 border border-white/10 rounded-lg className="w-full pl-10 pr-4 py-2 bg-white/5 border border-white/10 rounded-lg
focus:outline-none focus:border-blue-500 transition-colors" focus:outline-none focus:border-blue-500 transition-colors"
value={searchQuery} value={searchQuery}
@ -236,7 +236,7 @@ export function ReplaceVideoModal({
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}
> >
<Plus className="w-5 h-5" /> <Plus className="w-5 h-5" />
<span></span> <span>Generate new video</span>
</motion.button> </motion.button>
</motion.div> </motion.div>
)} )}
@ -252,14 +252,14 @@ export function ReplaceVideoModal({
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}
onClick={onClose} onClick={onClose}
> >
Reset
</motion.button> </motion.button>
<motion.button <motion.button
className="px-4 py-2 rounded-lg bg-blue-500 text-white hover:bg-blue-600 transition-colors" className="px-4 py-2 rounded-lg bg-blue-500 text-white hover:bg-blue-600 transition-colors"
whileHover={{ scale: 1.02 }} whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}
> >
Apply
</motion.button> </motion.button>
</div> </div>
</div> </div>

View File

@ -3,7 +3,6 @@
import React, { useRef, useEffect } from 'react'; import React, { useRef, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { Trash2, RefreshCw } from 'lucide-react'; import { Trash2, RefreshCw } from 'lucide-react';
import { GlassIconButton } from './glass-icon-button';
import { cn } from '@/public/lib/utils'; import { cn } from '@/public/lib/utils';
interface ScriptTabContentProps { interface ScriptTabContentProps {
@ -23,12 +22,6 @@ export function ScriptTabContent({
// 确保 taskSketch 是数组 // 确保 taskSketch 是数组
const sketches = Array.isArray(taskSketch) ? taskSketch : []; const sketches = Array.isArray(taskSketch) ? taskSketch : [];
// 模拟脚本数据
const mockScripts = sketches.map((_, index) => ({
id: `script-${index}`,
content: `这是第 ${index + 1} 个分镜的脚本内容,描述了场景中的主要动作和对话。这里可以添加更多细节来丰富场景表现。`
}));
// 自动滚动到选中项 // 自动滚动到选中项
useEffect(() => { useEffect(() => {
if (thumbnailsRef.current && scriptsRef.current) { if (thumbnailsRef.current && scriptsRef.current) {
@ -61,7 +54,7 @@ export function ScriptTabContent({
if (sketches.length === 0) { if (sketches.length === 0) {
return ( return (
<div className="flex flex-col items-center justify-center min-h-[400px] text-white/50"> <div className="flex flex-col items-center justify-center min-h-[400px] text-white/50">
<p></p> <p>No sketch data</p>
</div> </div>
); );
} }
@ -93,11 +86,11 @@ export function ScriptTabContent({
> >
<img <img
src={sketch.url} src={sketch.url}
alt={`分镜 ${index + 1}`} alt={`Sketch ${index + 1}`}
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
<div className="absolute bottom-0 left-0 right-0 p-1 bg-gradient-to-t from-black/60 to-transparent"> <div className="absolute bottom-0 left-0 right-0 p-1 bg-gradient-to-t from-black/60 to-transparent">
<span className="text-xs text-white/90"> {index + 1}</span> <span className="text-xs text-white/90">Scene {index + 1}</span>
</div> </div>
</motion.div> </motion.div>
))} ))}
@ -110,7 +103,7 @@ export function ScriptTabContent({
ref={scriptsRef} ref={scriptsRef}
className="flex overflow-x-auto hide-scrollbar py-2 gap-1" className="flex overflow-x-auto hide-scrollbar py-2 gap-1"
> >
{mockScripts.map((script, index) => { {sketches.map((script, index) => {
const isActive = currentSketchIndex === index; const isActive = currentSketchIndex === index;
return ( return (
<motion.div <motion.div
@ -127,9 +120,9 @@ export function ScriptTabContent({
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm whitespace-nowrap"> <span className="text-sm whitespace-nowrap">
{script.content} {script.script}
</span> </span>
{index < mockScripts.length - 1 && ( {index < sketches.length - 1 && (
<span className="text-white/20">|</span> <span className="text-white/20">|</span>
)} )}
</div> </div>
@ -156,7 +149,7 @@ export function ScriptTabContent({
<motion.textarea <motion.textarea
className="w-full h-full p-4 rounded-lg bg-white/5 backdrop-blur-sm border border-white/10 className="w-full h-full p-4 rounded-lg bg-white/5 backdrop-blur-sm border border-white/10
text-white/90 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-blue-500" text-white/90 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-blue-500"
value={mockScripts[currentSketchIndex]?.content} value={sketches[currentSketchIndex]?.script}
onChange={() => {}} onChange={() => {}}
layoutId="script-editor" layoutId="script-editor"
/> />
@ -171,7 +164,7 @@ export function ScriptTabContent({
> >
<img <img
src={sketches[currentSketchIndex]?.url} src={sketches[currentSketchIndex]?.url}
alt={`分镜 ${currentSketchIndex + 1}`} alt={`Sketch ${currentSketchIndex + 1}`}
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
</motion.div> </motion.div>
@ -179,20 +172,20 @@ export function ScriptTabContent({
{/* 操作按钮 */} {/* 操作按钮 */}
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<button <button
onClick={() => console.log('删除分镜')} onClick={() => console.log('Delete sketch')}
className="flex items-center justify-center gap-2 px-4 py-3 bg-red-500/10 hover:bg-red-500/20 className="flex items-center justify-center gap-2 px-4 py-3 bg-red-500/10 hover:bg-red-500/20
text-red-500 rounded-lg transition-colors" text-red-500 rounded-lg transition-colors"
> >
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
<span></span> <span>Delete sketch</span>
</button> </button>
<button <button
onClick={() => console.log('重新生成')} onClick={() => console.log('Regenerate')}
className="flex items-center justify-center gap-2 px-4 py-3 bg-blue-500/10 hover:bg-blue-500/20 className="flex items-center justify-center gap-2 px-4 py-3 bg-blue-500/10 hover:bg-blue-500/20
text-blue-500 rounded-lg transition-colors" text-blue-500 rounded-lg transition-colors"
> >
<RefreshCw className="w-4 h-4" /> <RefreshCw className="w-4 h-4" />
<span></span> <span>Regenerate</span>
</button> </button>
</div> </div>
</div> </div>

View File

@ -180,19 +180,19 @@ export function SettingsTabContent({ onSettingChange }: SettingsTabContentProps)
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-2 pb-2 border-b border-white/10"> <div className="flex items-center gap-2 pb-2 border-b border-white/10">
<div className="h-4 w-1 rounded-full bg-blue-500" /> <div className="h-4 w-1 rounded-full bg-blue-500" />
<h3 className="text-sm font-medium text-white/90"></h3> <h3 className="text-sm font-medium text-white/90">Basic settings</h3>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
{/* 工作模式 */} {/* 工作模式 */}
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm text-white/70"></label> <label className="text-sm text-white/70">Work mode</label>
{renderDropdown('mode', 'Mode', modeOptions, selectedMode, setSelectedMode)} {renderDropdown('mode', 'Mode', modeOptions, selectedMode, setSelectedMode)}
</div> </div>
{/* 分辨率 */} {/* 分辨率 */}
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm text-white/70"></label> <label className="text-sm text-white/70">Resolution</label>
{renderDropdown('resolution', 'Resolution', resolutionOptions, selectedResolution, setSelectedResolution)} {renderDropdown('resolution', 'Resolution', resolutionOptions, selectedResolution, setSelectedResolution)}
</div> </div>
</div> </div>
@ -202,19 +202,19 @@ export function SettingsTabContent({ onSettingChange }: SettingsTabContentProps)
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-2 pb-2 border-b border-white/10"> <div className="flex items-center gap-2 pb-2 border-b border-white/10">
<div className="h-4 w-1 rounded-full bg-emerald-500" /> <div className="h-4 w-1 rounded-full bg-emerald-500" />
<h3 className="text-sm font-medium text-white/90"></h3> <h3 className="text-sm font-medium text-white/90">Visual effects</h3>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
{/* 叠加效果 */} {/* 叠加效果 */}
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm text-white/70"></label> <label className="text-sm text-white/70">Overlay preset</label>
{renderDropdown('overlay', 'Overlay Preset', overlayOptions, selectedOverlay, setSelectedOverlay)} {renderDropdown('overlay', 'Overlay Preset', overlayOptions, selectedOverlay, setSelectedOverlay)}
</div> </div>
{/* 转场设定 */} {/* 转场设定 */}
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm text-white/70"></label> <label className="text-sm text-white/70">Transition preset</label>
{renderDropdown('transition', 'Transition Preset', transitionOptions, selectedTransition, setSelectedTransition)} {renderDropdown('transition', 'Transition Preset', transitionOptions, selectedTransition, setSelectedTransition)}
</div> </div>
</div> </div>
@ -224,25 +224,25 @@ export function SettingsTabContent({ onSettingChange }: SettingsTabContentProps)
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-2 pb-2 border-b border-white/10"> <div className="flex items-center gap-2 pb-2 border-b border-white/10">
<div className="h-4 w-1 rounded-full bg-purple-500" /> <div className="h-4 w-1 rounded-full bg-purple-500" />
<h3 className="text-sm font-medium text-white/90"></h3> <h3 className="text-sm font-medium text-white/90">Text style</h3>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
{/* 字幕风格 */} {/* 字幕风格 */}
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm text-white/70"></label> <label className="text-sm text-white/70">Subtitle preset</label>
{renderDropdown('subtitle', 'Subtitle Preset', subtitleOptions, selectedSubtitle, setSelectedSubtitle)} {renderDropdown('subtitle', 'Subtitle Preset', subtitleOptions, selectedSubtitle, setSelectedSubtitle)}
</div> </div>
{/* 文字效果 */} {/* 文字效果 */}
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm text-white/70"></label> <label className="text-sm text-white/70">Text preset</label>
{renderDropdown('textEffect', 'Text Preset', textEffectOptions, selectedTextEffect, setSelectedTextEffect)} {renderDropdown('textEffect', 'Text Preset', textEffectOptions, selectedTextEffect, setSelectedTextEffect)}
</div> </div>
{/* 贴纸预设 */} {/* 贴纸预设 */}
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm text-white/70"></label> <label className="text-sm text-white/70">Sticker preset</label>
{renderDropdown('watermark', 'Sticker Preset', watermarkOptions, selectedWatermark, setSelectedWatermark)} {renderDropdown('watermark', 'Sticker Preset', watermarkOptions, selectedWatermark, setSelectedWatermark)}
</div> </div>
</div> </div>
@ -252,7 +252,7 @@ export function SettingsTabContent({ onSettingChange }: SettingsTabContentProps)
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-2 pb-2 border-b border-white/10"> <div className="flex items-center gap-2 pb-2 border-b border-white/10">
<div className="h-4 w-1 rounded-full bg-amber-500" /> <div className="h-4 w-1 rounded-full bg-amber-500" />
<h3 className="text-sm font-medium text-white/90"></h3> <h3 className="text-sm font-medium text-white/90">Audio settings</h3>
</div> </div>
<div className="grid grid-cols-1 gap-6"> <div className="grid grid-cols-1 gap-6">
@ -261,7 +261,7 @@ export function SettingsTabContent({ onSettingChange }: SettingsTabContentProps)
<div className="p-4 rounded-xl bg-white/5 hover:bg-white/[0.07] transition-colors duration-200"> <div className="p-4 rounded-xl bg-white/5 hover:bg-white/[0.07] transition-colors duration-200">
{renderVolumeSlider( {renderVolumeSlider(
<Volume2 className="w-4 h-4 text-amber-500" />, <Volume2 className="w-4 h-4 text-amber-500" />,
"音效主音量", "SFX volume",
sfxVolume, sfxVolume,
setSfxVolume setSfxVolume
)} )}
@ -271,7 +271,7 @@ export function SettingsTabContent({ onSettingChange }: SettingsTabContentProps)
<div className="p-4 rounded-xl bg-white/5 hover:bg-white/[0.07] transition-colors duration-200"> <div className="p-4 rounded-xl bg-white/5 hover:bg-white/[0.07] transition-colors duration-200">
{renderVolumeSlider( {renderVolumeSlider(
<Mic className="w-4 h-4 text-amber-500" />, <Mic className="w-4 h-4 text-amber-500" />,
"配音主音量", "Voice volume",
voiceVolume, voiceVolume,
setVoiceVolume setVoiceVolume
)} )}
@ -283,7 +283,7 @@ export function SettingsTabContent({ onSettingChange }: SettingsTabContentProps)
<div className="p-4 rounded-xl bg-white/5 hover:bg-white/[0.07] transition-colors duration-200"> <div className="p-4 rounded-xl bg-white/5 hover:bg-white/[0.07] transition-colors duration-200">
{renderVolumeSlider( {renderVolumeSlider(
<Radio className="w-4 h-4 text-amber-500" />, <Radio className="w-4 h-4 text-amber-500" />,
"媒体音频主音量", "Media volume",
mediaVolume, mediaVolume,
setMediaVolume setMediaVolume
)} )}
@ -293,7 +293,7 @@ export function SettingsTabContent({ onSettingChange }: SettingsTabContentProps)
<div className="p-4 rounded-xl bg-white/5 hover:bg-white/[0.07] transition-colors duration-200"> <div className="p-4 rounded-xl bg-white/5 hover:bg-white/[0.07] transition-colors duration-200">
{renderVolumeSlider( {renderVolumeSlider(
<Music className="w-4 h-4 text-amber-500" />, <Music className="w-4 h-4 text-amber-500" />,
"音乐主音量", "Music volume",
musicVolume, musicVolume,
setMusicVolume setMusicVolume
)} )}
@ -306,9 +306,9 @@ export function SettingsTabContent({ onSettingChange }: SettingsTabContentProps)
<Volume2 className="w-4 h-4 text-amber-500" /> <Volume2 className="w-4 h-4 text-amber-500" />
</div> </div>
<div className="flex-1"> <div className="flex-1">
<p className="text-sm text-amber-500"></p> <p className="text-sm text-amber-500">Volume mixing tips</p>
<p className="text-xs text-amber-500/80 mt-0.5"> <p className="text-xs text-amber-500/80 mt-0.5">
It is recommended to maintain a balance between SFX, voice, media audio, and music to achieve the best auditory experience
</p> </p>
</div> </div>
</div> </div>

View File

@ -39,14 +39,6 @@ export function VideoTabContent({
// 确保 taskSketch 是数组 // 确保 taskSketch 是数组
const sketches = Array.isArray(taskSketch) ? taskSketch : []; const sketches = Array.isArray(taskSketch) ? taskSketch : [];
// 模拟视频数据
const mockVideos = sketches.map((_, index) => ({
id: `video-${index}`,
url: 'https://example.com/video.mp4', // 替换为实际视频URL
duration: '00:21',
description: `这是第 ${index + 1} 个分镜的视频描述,包含了场景中的主要动作和表现。`
}));
// 自动滚动到选中项 // 自动滚动到选中项
useEffect(() => { useEffect(() => {
if (thumbnailsRef.current && videosRef.current) { if (thumbnailsRef.current && videosRef.current) {
@ -98,7 +90,7 @@ export function VideoTabContent({
if (sketches.length === 0) { if (sketches.length === 0) {
return ( return (
<div className="flex flex-col items-center justify-center min-h-[400px] text-white/50"> <div className="flex flex-col items-center justify-center min-h-[400px] text-white/50">
<p></p> <p>No sketch data</p>
</div> </div>
); );
} }
@ -138,7 +130,7 @@ export function VideoTabContent({
onMouseLeave={(e) => e.currentTarget.pause()} onMouseLeave={(e) => e.currentTarget.pause()}
/> />
<div className="absolute bottom-0 left-0 right-0 p-1 bg-gradient-to-t from-black/60 to-transparent"> <div className="absolute bottom-0 left-0 right-0 p-1 bg-gradient-to-t from-black/60 to-transparent">
<span className="text-xs text-white/90"> {index + 1}</span> <span className="text-xs text-white/90">Scene {index + 1}</span>
</div> </div>
</motion.div> </motion.div>
))} ))}
@ -151,11 +143,11 @@ export function VideoTabContent({
ref={videosRef} ref={videosRef}
className="flex overflow-x-auto hide-scrollbar py-2 gap-1" className="flex overflow-x-auto hide-scrollbar py-2 gap-1"
> >
{mockVideos.map((video, index) => { {sketches.map((video, index) => {
const isActive = currentSketchIndex === index; const isActive = currentSketchIndex === index;
return ( return (
<motion.div <motion.div
key={video.id} key={video.id || index}
className={cn( className={cn(
'flex-shrink-0 cursor-pointer transition-all duration-300', 'flex-shrink-0 cursor-pointer transition-all duration-300',
isActive ? 'text-white' : 'text-white/50 hover:text-white/80' isActive ? 'text-white' : 'text-white/50 hover:text-white/80'
@ -168,9 +160,9 @@ export function VideoTabContent({
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm whitespace-nowrap"> <span className="text-sm whitespace-nowrap">
{video.description} {video.script}
</span> </span>
{index < mockVideos.length - 1 && ( {index < sketches.length - 1 && (
<span className="text-white/20">|</span> <span className="text-white/20">|</span>
)} )}
</div> </div>
@ -192,7 +184,7 @@ export function VideoTabContent({
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }} transition={{ delay: 0.1 }}
> >
<h3 className="text-sm font-medium mb-2"></h3> <h3 className="text-sm font-medium mb-2">Replace video</h3>
<div className="flex gap-4"> <div className="flex gap-4">
<motion.button <motion.button
className={cn( className={cn(
@ -209,7 +201,7 @@ export function VideoTabContent({
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}
> >
<Upload className="w-6 h-6" /> <Upload className="w-6 h-6" />
<span></span> <span>Upload video</span>
</motion.button> </motion.button>
<motion.button <motion.button
@ -227,7 +219,7 @@ export function VideoTabContent({
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}
> >
<Library className="w-6 h-6" /> <Library className="w-6 h-6" />
<span></span> <span>Library</span>
</motion.button> </motion.button>
<motion.button <motion.button
@ -245,7 +237,7 @@ export function VideoTabContent({
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}
> >
<Wand2 className="w-6 h-6" /> <Wand2 className="w-6 h-6" />
<span></span> <span>Generate video</span>
</motion.button> </motion.button>
</div> </div>
</motion.div> </motion.div>
@ -261,7 +253,7 @@ export function VideoTabContent({
<div className="space-y-4"> <div className="space-y-4">
{/* 视频截取 */} {/* 视频截取 */}
<div className="p-4 rounded-lg bg-white/5"> <div className="p-4 rounded-lg bg-white/5">
<h3 className="text-sm font-medium mb-2"></h3> <h3 className="text-sm font-medium mb-2">Video clip</h3>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<input <input
type="text" type="text"
@ -269,7 +261,7 @@ export function VideoTabContent({
className="w-20 px-3 py-1 bg-white/5 border border-white/10 rounded-lg className="w-20 px-3 py-1 bg-white/5 border border-white/10 rounded-lg
text-center focus:outline-none focus:border-blue-500" text-center focus:outline-none focus:border-blue-500"
/> />
<span className="text-white/50"></span> <span className="text-white/50">To</span>
<input <input
type="text" type="text"
placeholder="00:00" placeholder="00:00"
@ -281,9 +273,9 @@ export function VideoTabContent({
{/* 转场设置 */} {/* 转场设置 */}
<div className="p-4 rounded-lg bg-white/5"> <div className="p-4 rounded-lg bg-white/5">
<h3 className="text-sm font-medium mb-2"></h3> <h3 className="text-sm font-medium mb-2">Transition</h3>
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
{['淡入淡出', '滑动', '缩放'].map((transition) => ( {['Fade', 'Slide', 'Zoom'].map((transition) => (
<motion.button <motion.button
key={transition} key={transition}
className="px-3 py-1 bg-white/5 hover:bg-white/10 rounded-lg text-sm" className="px-3 py-1 bg-white/5 hover:bg-white/10 rounded-lg text-sm"
@ -298,7 +290,7 @@ export function VideoTabContent({
{/* 音量调节 */} {/* 音量调节 */}
<div className="p-4 rounded-lg bg-white/5"> <div className="p-4 rounded-lg bg-white/5">
<h3 className="text-sm font-medium mb-2"></h3> <h3 className="text-sm font-medium mb-2">Volume</h3>
<input <input
type="range" type="range"
min="0" min="0"
@ -385,8 +377,6 @@ export function VideoTabContent({
<Volume2 className="w-5 h-5" /> <Volume2 className="w-5 h-5" />
)} )}
</motion.button> </motion.button>
<span className="text-sm">{mockVideos[currentSketchIndex]?.duration}</span>
</div> </div>
</div> </div>
</div> </div>
@ -395,24 +385,24 @@ export function VideoTabContent({
{/* 操作按钮 */} {/* 操作按钮 */}
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<motion.button <motion.button
onClick={() => console.log('删除分镜')} onClick={() => console.log('Delete sketch')}
className="flex items-center justify-center gap-2 px-4 py-3 bg-red-500/10 hover:bg-red-500/20 className="flex items-center justify-center gap-2 px-4 py-3 bg-red-500/10 hover:bg-red-500/20
text-red-500 rounded-lg transition-colors" text-red-500 rounded-lg transition-colors"
whileHover={{ scale: 1.02 }} whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}
> >
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
<span></span> <span>Delete sketch</span>
</motion.button> </motion.button>
<motion.button <motion.button
onClick={() => console.log('重新生成')} onClick={() => console.log('Regenerate')}
className="flex items-center justify-center gap-2 px-4 py-3 bg-blue-500/10 hover:bg-blue-500/20 className="flex items-center justify-center gap-2 px-4 py-3 bg-blue-500/10 hover:bg-blue-500/20
text-blue-500 rounded-lg transition-colors" text-blue-500 rounded-lg transition-colors"
whileHover={{ scale: 1.02 }} whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}
> >
<RefreshCw className="w-4 h-4" /> <RefreshCw className="w-4 h-4" />
<span></span> <span>Regenerate</span>
</motion.button> </motion.button>
</div> </div>
</div> </div>

View File

@ -2,27 +2,27 @@ import {
TaskObject, TaskObject,
SketchItem, SketchItem,
VideoItem, VideoItem,
MOCK_SKETCH_URLS, getRandomMockData
MOCK_SKETCH_SCRIPT,
MOCK_VIDEO_URLS,
MOCK_SKETCH_COUNT
} from './constants'; } from './constants';
// 当前选择的mock数据
let selectedMockData = getRandomMockData();
// 模拟接口请求 获取任务详情 // 模拟接口请求 获取任务详情
export const getTaskDetail = async (taskId: string): Promise<TaskObject> => { export const getTaskDetail = async (taskId: string): Promise<TaskObject> => {
// const response = await fetch(`/api/task/${taskId}`); // 每次获取任务详情时重新随机选择数据
// const data = await response.json(); selectedMockData = getRandomMockData();
// mock data
const data: TaskObject = { const data: TaskObject = {
projectId: 'projectId-123', projectId: selectedMockData.detail.projectId,
projectName: "Project 1", projectName: selectedMockData.detail.projectName,
taskId: taskId, taskId: taskId,
taskName: "Task 1", taskName: selectedMockData.detail.taskName,
taskDescription: "Task 1 Description", taskDescription: selectedMockData.detail.taskDescription,
taskStatus: "1", // '1' 绘制分镜、'2' 绘制角色、'3' 生成分镜视频、'4' 视频后期制作、'5' 最终成品 taskStatus: selectedMockData.detail.taskStatus,
taskProgress: 0, taskProgress: 0,
mode: 'auto', // 全自动模式、人工干预模式 mode: selectedMockData.detail.mode,
resolution: '1080p', // 1080p、2160p resolution: selectedMockData.detail.resolution.toString(),
}; };
return data; return data;
}; };
@ -32,14 +32,17 @@ export const getTaskSketch = async (
taskId: string, taskId: string,
onProgress: (sketch: SketchItem, index: number) => void onProgress: (sketch: SketchItem, index: number) => void
): Promise<void> => { ): Promise<void> => {
const sketchData = selectedMockData.sketch;
const totalSketches = sketchData.length;
// 模拟分批获取分镜草图 // 模拟分批获取分镜草图
for (let i = 0; i < MOCK_SKETCH_COUNT; i++) { for (let i = 0; i < totalSketches; i++) {
await new Promise(resolve => setTimeout(resolve, 2000)); // 模拟2秒延迟 await new Promise(resolve => setTimeout(resolve, 2000)); // 模拟2秒延迟
const newSketch: SketchItem = { const newSketch: SketchItem = {
id: `sketch-${i}`, id: `sketch-${i}`,
url: MOCK_SKETCH_URLS[i % MOCK_SKETCH_URLS.length], url: sketchData[i].url,
script: MOCK_SKETCH_SCRIPT[i % MOCK_SKETCH_SCRIPT.length], script: sketchData[i].script,
status: 'done' status: 'done'
}; };
@ -68,14 +71,17 @@ export const getTaskVideo = async (
sketchCount: number, sketchCount: number,
onProgress: (video: VideoItem, index: number) => void onProgress: (video: VideoItem, index: number) => void
): Promise<void> => { ): Promise<void> => {
const videoData = selectedMockData.video;
const totalVideos = videoData.length;
// 模拟分批获取分镜视频 // 模拟分批获取分镜视频
for (let i = 0; i < sketchCount; i++) { for (let i = 0; i < totalVideos; i++) {
await new Promise(resolve => setTimeout(resolve, 5000)); // 模拟5秒延迟 await new Promise(resolve => setTimeout(resolve, 5000)); // 模拟5秒延迟
const newVideo: VideoItem = { const newVideo: VideoItem = {
id: `video-${i}`, id: `video-${i}`,
url: MOCK_VIDEO_URLS[i % MOCK_VIDEO_URLS.length], url: videoData[i].url,
script: MOCK_SKETCH_SCRIPT[i % MOCK_SKETCH_SCRIPT.length], script: videoData[i].script,
status: 'done' status: 'done'
}; };

View File

@ -1,30 +1,222 @@
export const MOCK_SKETCH_URLS = [ // 5组mock数据
'https://d3phaj0sisr2ct.cloudfront.net/app/gen4/object-reference/welcome-ref-1.jpg', export const MOCK_DATA = [
'https://d3phaj0sisr2ct.cloudfront.net/app/gen4/object-reference/welcome-ref-2.jpg', {
'https://d3phaj0sisr2ct.cloudfront.net/app/gen4/object-reference/welcome-ref-3.jpg', id: 1,
'https://d3phaj0sisr2ct.cloudfront.net/app/gen4/object-reference/welcome-ref-4.jpg', detail: {
projectId: '1',
projectName: 'Home and Away',
taskId: '1',
taskName: 'Episode 1: Flood Disaster',
taskDescription: 'A story about a factory accident',
taskStatus: '1',
mode: 'auto',
resolution: 1024,
sketch_count: 18,
role_count: 1,
},
sketch: [{ // sketch_count个分镜草图
url: 'https://cdn.qikongjian.com/1751484624095_ypdp4h.png',
script: 'After the flood disaster in the middle and lower reaches of the Yangtze River in 1981\nFlood Documentary Camera\nAccompanied by the sound of news broadcasts.',
bg_rgb: ['RGB(58, 58, 59)', 'RGB(189, 186, 185)', 'RGB(114, 113, 115)']
}, {
url: 'https://cdn.qikongjian.com/1751484962956_7e2kv2.png',
script: 'A big tree has exposed its roots and is lying horizontally on the ground',
bg_rgb: ['RGB(12, 13, 13)', 'RGB(42, 46, 47)', 'RGB(101, 107, 109)']
},{
url: 'https://cdn.qikongjian.com/1751486620736_51i98q.png',
script: 'The leader of the factory rushed over and gave Chen Haiqing an umbrella: Director Chen, our factory\'s post disaster work has not been done yet. Why did you come in person! I asked them to report to you',
bg_rgb: ['RGB(3, 5, 8)', 'RGB(42, 46, 56)', 'RGB(133, 132, 142)']
},{
url: 'https://cdn.qikongjian.com/1751487101428_1inxi2.png',
script: 'Chen Haiqing interrupted: Don\'t waste time, disaster relief is urgent! Chen Haiqing took a few steps and continued to bend down to help move the stones.',
bg_rgb: ['RGB(42, 27, 10)', 'RGB(135, 104, 72)', 'RGB(218, 195, 168)']
},{
url: 'https://cdn.qikongjian.com/1751487200821_zp0sr8.png',
script: 'The factory leader immediately held an umbrella, feeling embarrassed',
bg_rgb: ['RGB(22, 19, 14)', 'RGB(94, 80, 62)', 'RGB(178, 165, 150)']
},{
url: 'https://cdn.qikongjian.com/1751487951909_yzcgk1.png',
script: "At this moment, a woman's shout came from inside the office building.",
bg_rgb: ['RGB(11, 12, 13)', 'RGB(61, 58, 48)', 'RGB(162, 161, 142)']
},{
url: 'https://cdn.qikongjian.com/1751488430395_erejxa.png',
script: 'Behind him, the leader of the branch factory whispered to the worker group leader: The leader of the main factory is coming. Just show him these messy things!',
bg_rgb: ['RGB(8, 13, 21)', 'RGB(53, 60, 69)', 'RGB(155, 148, 142)']
},{
url: 'https://cdn.qikongjian.com/1751488807298_nd46xl.png ',
script: 'clinic. Night. within\nOn the stool made by Director Wang, gently covering her head: Cai Xiaoyan. What\'s wrong with you! Didn\'t you see Dr. Li treating me?',
bg_rgb: ['RGB(14, 34, 28)', 'RGB(59, 112, 100)', 'RGB(189, 216, 214)']
},{
url: 'https://cdn.qikongjian.com/1751490196476_rxwwi3.jpg',
script: 'A medium shot, in a gritty 1980s realistic style, establishes the cramped infirmary, its walls a faded institutional mint green. The paunchy Director Wang [CH-06], wearing a tight, pale custard-colored shirt [COSTUME-006], is sitting on a stool in the middle of the room. The camera then smoothly pushes in to focus on him as he holds his head melodramatically. Doctor Li [CH-03], in his white doctor\'s coat [COSTUME-005], nervously dabs at a tiny cut on Wang\'s head. From his position, Director Wang [CH-06] looks towards the doorway and speaks with a whiny, indignant tone to someone off-screen.',
bg_rgb: ['RGB(83, 98, 85)', 'RGB(24, 33, 26)', 'RGB(147, 149, 143)']
},{
url: 'https://cdn.qikongjian.com/1751490347325_n9vvzm.png',
script: 'A tight close-up on the intense face of Cai Xiaoyan [CH-02], who wears a practical floral blouse [COSTUME-004]. She completely ignores Director Wang [CH-06] and locks her fiery eyes directly on Doctor Li [CH-03]. She grabs the doctor\'s arm, her grip firm and desperate. The background is a soft blur due to a very shallow depth of field, and the camera performs a slight push-in to heighten her intensity as she speaks, her voice urgent and breathless.',
bg_rgb: ['RGB(178, 185, 183)', 'RGB(130, 98, 82)', 'RGB(23, 22, 19)']
},{
url: 'https://cdn.qikongjian.com/1751490513785_f5yz9l.png',
script: 'An over-the-shoulder shot from behind Cai Xiaoyan [CH-02]. We see Doctor Li [CH-03] turn his head helplessly toward Director Wang [CH-06], who is sitting on the stool, seeking permission or a way out. He speaks weakly, abdicating responsibility.',
bg_rgb: ['RGB(161, 167, 161)', 'RGB(12, 18, 20)', 'RGB(91, 105, 106)']
},{
url: 'https://cdn.qikongjian.com/1751490589772_vfp3ea.png',
script: ' The shot starts on Director Wang [CH-06], who visibly shrinks, his head bowed in submission. A smooth rack focus shifts to a clean two-shot of Chen Haiqing [CH-01] and the Branch Factory Leader [CH-04]. Chen Haiqing [CH-01] turns away from the defeated Wang, his point made, and addresses the Leader. His tone is now calm and inquisitive, as if the confrontation meant nothing.',
bg_rgb: ['RGB(160, 170, 172)', 'RGB(18, 61, 100)', 'RGB(115, 109, 103)']
},{
url: 'https://cdn.qikongjian.com/1751490682587_ezu790.png',
script: ' The scene opens with a static camera positioned directly behind Chen Haiqing as he walks slowly down a dimly lit hallway. The focus is entirely on his back, emphasizing his solitary journey. Suddenly, Chen Haiqing comes to an abrupt halt. His sudden stillness becomes the focal point, conveying without words that he has just received significant news. This moment of absolute immobility speaks volumes, capturing the gravity of the situation.',
bg_rgb: ['RGB(39, 56, 59)', 'RGB(15, 21, 23)', 'RGB(104, 125, 130)']
},{
url: 'https://cdn.qikongjian.com/1751490741368_k5xhk3.png',
script: 'Cutting to a side angle, the camera now reveals the Branch Factory Leader standing nearby. He cautiously peers at Chen Haiqing\'s motionless figure, clearly trying to gauge the reaction. Misinterpreting the heavy silence, the Leader decides to break the tension. In a guarded, almost teasing tone, he addresses Chen Haiqing, attempting to elicit some form of response. However, his words only serve to heighten the suspense.',
bg_rgb: ['RGB(176, 171, 161)', 'RGB(20, 37, 42)', 'RGB(101, 104, 99)']
},{
url: 'https://cdn.qikongjian.com/1751490814138_oj9lcr.png',
script: 'To amplify the profound tension, the camera performs a very slow, almost imperceptible push-in towards Chen Haiqing\'s face. This subtle movement draws the viewer deeper into the moment, making the silence even more powerful. The combination of Chen Haiqing\'s unyielding stare and the camera\'s gradual advance underscores the high stakes of the interaction, leaving the audience hanging on the edge of their seats. The scene ends on this note of intense, unresolved tension.',
bg_rgb: ['RGB(19, 22, 22)', 'RGB(49, 60, 63)', 'RGB(165, 134, 113)']
},{
url: 'https://cdn.qikongjian.com/1751490898729_lx041y.png',
script: 'The camera captures Chen Haiqing in a contemplative medium shot, his eyes reflecting deep concern as he processes the weight of the situation. The lighting creates dramatic shadows across his face, emphasizing the burden of leadership he carries. His expression shifts subtly from determination to worry, revealing the human side behind his authoritative facade.',
bg_rgb: ['RGB(19, 22, 22)', 'RGB(49, 60, 63)', 'RGB(165, 134, 113)']
},{
url: 'https://cdn.qikongjian.com/1751490969275_xpgcb6.png',
script: 'A wide establishing shot reveals the industrial setting in its full gritty reality. Steam rises from various pipes and machinery, creating an atmosphere of urgency and chaos. Workers move like shadows in the background, their silhouettes barely visible through the haze, while the harsh fluorescent lighting casts an unforgiving glow over the entire scene.',
bg_rgb: ['RGB(19, 22, 22)', 'RGB(49, 60, 63)', 'RGB(165, 134, 113)']
},{
url: 'https://cdn.qikongjian.com/1751491088171_7de9dx.png',
script: 'The camera employs a handheld technique to follow Chen Haiqing as he navigates through the crowded workspace. The slightly unstable movement adds urgency to his mission, while workers part to make way for him. His focused stride and unwavering gaze demonstrate his determination to address the crisis at hand.',
bg_rgb: ['RGB(19, 22, 22)', 'RGB(49, 60, 63)', 'RGB(165, 134, 113)']
},{
url: 'https://cdn.qikongjian.com/1751491207572_s1vnp9.png',
script: 'A dramatic low-angle shot emphasizes Chen Haiqing\'s commanding presence as he stands amid the industrial chaos. The camera looks up at him, creating a sense of authority and power. Steam and smoke swirl around him like a halo, while the harsh overhead lighting creates strong contrasts that highlight his determined expression.',
bg_rgb: ['RGB(19, 22, 22)', 'RGB(49, 60, 63)', 'RGB(165, 134, 113)']
},{
url: 'https://cdn.qikongjian.com/1751491276753_rgvpck.png',
script: 'The scene transitions to a quiet moment where Chen Haiqing pauses near a window, gazing out at the disaster-stricken landscape. The camera captures him in profile, creating a silhouette against the pale morning light. This contemplative beat allows the audience to see his vulnerability beneath the strong exterior.',
bg_rgb: ['RGB(19, 22, 22)', 'RGB(49, 60, 63)', 'RGB(165, 134, 113)']
},{
url: 'https://cdn.qikongjian.com/1751491374450_z22k03.png',
script: 'The final shot of the sequence uses a slow zoom-out to reveal Chen Haiqing in the context of the larger disaster area. As the camera pulls back, we see the full scale of the destruction and the monumental task ahead. His solitary figure becomes smaller but more significant, representing hope and leadership in the face of overwhelming adversity.',
bg_rgb: ['RGB(19, 22, 22)', 'RGB(49, 60, 63)', 'RGB(165, 134, 113)']
}],
roles: [{ // role_count个角色
name: '陈海清',
url: 'https://cdn.qikongjian.com/1751486026891_zieiq5.png',
sound: 'https://cdn.qikongjian.com/audio/1750385931_99a8fb42-af89-4ae9-841a-a49869f026bd_text_to_video_0.mp3',
soundDescription: 'script-123',
roleDescription: 'Brief: A portrait of a male in his late 20s or early 30s from South Korea, with a neat, side-parted hairstyle. He has an intense, focused gaze, and a calm, observant, and authoritative demeanor, small scar in his left face, eyes down.\nRole: Protagonist\nGender: Male\nPhysique & Age: Late 20s. Lean, wiry build suggesting practical strength rather than gym fitness. Stands tall with a naturally confident posture.\nKey Visual Anchors: A thin, faded white scar, about an inch long, running vertically on his left cheekbone, just below the eye. It\'s subtle and only visible in certain light. His hands are capable and calloused, evidence of a man who is not afraid of work.\nHairstyle: Short, neat, dark hair, always impeccably side-parted, even in the midst of chaos.\nDefault Demeanor: Calm and observant. His expression is typically neutral, with an intense, focused gaze that makes others feel he is seeing right through them. He moves with purpose and efficiency.',
},{
name: '蔡晓艳',
url: 'https://cdn.qikongjian.com/1751491922865_ryc7be.png',
sound: 'https://cdn.qikongjian.com/audio/1750385931_99a8fb42-af89-4ae9-841a-a49869f026bd_text_to_video_0.mp3',
soundDescription: 'script-123',
roleDescription: 'Brief: A medium close-up shot features a young adult South Korean woman with long, flowing dark brown hair. Her hair cascades over her shoulders and back, with some strands artfully pushed behind her right ear. Her fair complexion is illuminated by the soft lighting, highlighting her delicate facial features. She has a high forehead, well-defined eyebrows, and dark eyes that gaze directly at the viewer. Her nose is narrow and her lips are full, with a natural, light pink hue.\nRole: Female Lead\nGender: Female\nPhysique & Age: Mid-20s. Slender but resilient frame. Her movements are quick and decisive, fueled by nervous energy and fierce determination.\nKey Visual Anchors: Her long, dark brown hair is often slightly disheveled from work and stress, with loose strands framing her face. She has a habit of pushing her hair back impatiently. Her direct, unblinking dark eyes are her most powerful feature.\nHairstyle: Long, dark brown hair, naturally wavy. Mostly worn down due to practicality, but she pushes it back from her face when focused or angry.\nDefault Demeanor: Fiery, passionate, and defiant. She carries an air of righteous anger and impatience with incompetence. Her default expression is a worried frown, but it can quickly flash into a determined glare.',
},{
name: '李医生',
url: 'https://cdn.qikongjian.com/1751489036973_3ytd1e.png',
sound: 'https://cdn.qikongjian.com/audio/1750385931_99a8fb42-af89-4ae9-841a-a49869f026bd_text_to_video_0.mp3',
soundDescription: 'script-123',
roleDescription: 'Brief: A close-up, eye-level, chest-up portrait shot features an adult male with light skin and short, dark hair, styled neatly, with some strands combed forward over his forehead. He has dark eyebrows and is wearing thin-rimmed, round glasses. His eyes are dark, and he has a subtle smile with his lips closed. An asia south korea man.\nRole: Supporting Character\nGender: Male\nPhysique & Age: Early 30s. Thin, slightly stooped posture from poring over patients and books. Not physically imposing.\nKey Visual Anchors: His thin, wire-rimmed round glasses are his defining feature. He is constantly pushing them up the bridge of his nose, especially when nervous or thinking.\nHairstyle: Short, neat dark hair, slightly overgrown, with a fringe that falls over his forehead.\nDefault Demeanor: Hesitant and conflict-averse. He is a good man caught between duty and bureaucracy. His expression is often one of weariness and indecision.',
},{
name: '分厂领导',
url: 'https://cdn.qikongjian.com/1751486823013_5wx7fm.png',
sound: 'https://cdn.qikongjian.com/audio/1750385931_99a8fb42-af89-4ae9-841a-a49869f026bd_text_to_video_0.mp3',
soundDescription: 'script-123',
roleDescription: 'Brief: A studio portrait shows a male subject from the chest up, positioned slightly off-center to the left, with his head tilted slightly to the right. He is in his twenties or early thirties, with fair skin and dark, short, styled hair. He has a subtle smile, and his eyes are dark. south korea, asian.\nRole: Supporting Character\nGender: Male\nPhysique & Age: Early 30s. A bit soft around the middle, clearly unused to manual labor. Fair skin contrasts with the grimy environment.\nKey Visual Anchors: Wears a wristwatch that is slightly too nice for his position, which he nervously glances at. Perspires easily under pressure, with a constant sheen of sweat on his forehead.\nHairstyle: Short, neatly styled dark hair that he tries to keep in place, but which becomes slightly disheveled in the rain and chaos.\nDefault Demeanor: Sycophantic and anxious. He is perpetually trying to please his superiors and maintain control, but is clearly out of his depth. His smile is quick but insincere.',
},{
name: '王主任',
url: 'https://cdn.qikongjian.com/1751488727632_olhrxa.png',
sound: 'https://cdn.qikongjian.com/audio/1750385931_99a8fb42-af89-4ae9-841a-a49869f026bd_text_to_video_0.mp3',
soundDescription: 'script-123',
roleDescription: 'Role: Minor Antagonist\nGender: Male\nPhysique & Age: Late 40s. Paunchy, with a soft physique that speaks to a sedentary desk job. His face is fleshy and prone to turning red when he\'s angry or exerting himself.\nKey Visual Anchors: A noticeable bald spot on the crown of his head, surrounded by thinning, carefully combed-over graying hair. He clutches his head in the infirmary not just from a supposed injury, but as a gesture of dramatic self-pity.\nHairstyle: Thinning, graying dark hair, combed over a prominent bald spot.\nDefault Demeanor: Pompous, selfish, and cowardly. He carries himself with an unearned sense of importance, speaking in a whiny, complaining tone. His default expression is one of slighted entitlement.',
}],
video: [{ // sketch_count个分镜视频以及对应的音频
url: 'https://cdn.qikongjian.com/1751483686756_01p12v.mp4',
script: 'After the flood disaster in the middle and lower reaches of the Yangtze River in 1981\nFlood Documentary Camera\nAccompanied by the sound of news broadcasts.',
audio: 'https://cdn.qikongjian.com/audio/1750385931_99a8fb42-af89-4ae9-841a-a49869f026bd_text_to_video_0.mp3',
},{
url: 'https://cdn.qikongjian.com/1751486094044_ow9qrf.mp4',
script: 'The leader of the factory rushed over and gave Chen Haiqing an umbrella: Director Chen, our factory\'s post disaster work has not been done yet. Why did you come in person! I asked them to report to you',
audio: 'https://cdn.qikongjian.com/audio/1750385931_99a8fb42-af89-4ae9-841a-a49869f026bd_text_to_video_0.mp3',
},{
url: 'https://cdn.qikongjian.com/1751487266547_exqyvu.mp4',
script: 'At this moment, the leader of the factory rushed over and gave Chen Haiqing an umbrella: Director Chen, our factory\'s post disaster work has not been done yet. Why did you come in person! I asked them to report to you\nChen Haiqing interrupted: Don\'t waste time, disaster relief is urgent!\nChen Haiqing took a few steps and continued to bend down to help move the stones.\nThe factory leader immediately held an umbrella, feeling embarrassed',
audio: 'https://cdn.qikongjian.com/audio/1750385931_99a8fb42-af89-4ae9-841a-a49869f026bd_text_to_video_0.mp3',
},{
url: 'https://cdn.qikongjian.com/1751487608876_y8vu6z.mp4',
script: 'At this moment, a woman\'s shout came from inside the office building.\nOff screen voice of Cai Xiaoyan:\nDr. Li! If you don\'t come with me again, I\'ll smash your clinic!\nChen Haiqing looked up.',
audio: 'https://cdn.qikongjian.com/audio/1750385931_99a8fb42-af89-4ae9-841a-a49869f026bd_text_to_video_0.mp3',
},{
url: 'https://cdn.qikongjian.com/1751488183017_z6rrec.mp4',
script: 'Behind him, the leader of the branch factory whispered to the worker group leader: The leader of the main factory is coming. Just show him these messy things!',
audio: 'https://cdn.qikongjian.com/audio/1750385931_99a8fb42-af89-4ae9-841a-a49869f026bd_text_to_video_0.mp3',
},{
url: 'https://cdn.qikongjian.com/1751488527285_2hwsii.mp4',
script: 'clinic. Night. within\nOn the stool made by Director Wang, gently covering her head: Cai Xiaoyan. What\'s wrong with you! Didn\'t you see Dr. Li treating me?\nCai Xiaoyan looked at Dr. Li and pulled the standing Dr. Li: Dr. Li, my sister-in-law has been trapped in the workshop for several days with a big belly. Now there\'s massive bleeding!',
audio: 'https://cdn.qikongjian.com/audio/1750385931_99a8fb42-af89-4ae9-841a-a49869f026bd_text_to_video_0.mp3',
},{
url: 'https://cdn.qikongjian.com/1751489110005_hd1erq.mp4',
script: 'Cai Xiaoyan looked at Dr. Li and pulled the standing Dr. Li: Dr. Li, my sister-in-law has been trapped in the workshop for several days with a big belly. Now there\'s massive bleeding!\nDr. Li looked at her and said, \'Wait, wait, my wound hasn\'t been examined yet.\'',
audio: 'https://cdn.qikongjian.com/audio/1750385931_99a8fb42-af89-4ae9-841a-a49869f026bd_text_to_video_0.mp3',
},{
url: 'https://cdn.qikongjian.com/1751489834974_t1easr.mp4',
script: 'Cai Xiaoyan stepped forward and gave Director Wang a glare.\nCai Xiaoyan was furious: Check, check again, the wound is almost healed!',
audio: 'https://cdn.qikongjian.com/audio/1750385931_99a8fb42-af89-4ae9-841a-a49869f026bd_text_to_video_0.mp3',
},{
url: 'https://cdn.qikongjian.com/1751490211998_n5n70s.mp4',
script: 'Director Wang: How do you know that my injuries won\'t kill anyone? What if they get infected? The toxin may have already entered the skull!',
audio: 'https://cdn.qikongjian.com/audio/1750385931_99a8fb42-af89-4ae9-841a-a49869f026bd_text_to_video_0.mp3',
},{
url: 'https://cdn.qikongjian.com/1751490362185_lq90ih.mp4',
script: 'Cai Xiaoyan: Then you can endure it, you can\'t die!\nDirector Wang snorted lightly, but Cai Xiaoyan ignored him and just pulled the doctor away\nCai Xiaoyan: Dr. Li, come with me. He\'s dead, and I\'ll take responsibility!',
audio: 'https://cdn.qikongjian.com/audio/1750385931_99a8fb42-af89-4ae9-841a-a49869f026bd_text_to_video_0.mp3',
},{
url: 'https://cdn.qikongjian.com/1751490546133_sylhql.mp4',
script: 'At this moment, Cai Xiaoyan pulled Dr. Li out of the medical room. Passing by Chen Haiqing standing at the door.\nThis bright and familiar face flashed before Chen Haiqing\'s eyes, and he was pleasantly surprised',
audio: 'https://cdn.qikongjian.com/audio/1750385931_99a8fb42-af89-4ae9-841a-a49869f026bd_text_to_video_0.mp3',
},{
url: 'https://cdn.qikongjian.com/1751490641482_6zs3di.mp4',
script: 'Director Wang cursed and chased after: Cai Xiaoyan! You wait (I\'m so angry)',
audio: 'https://cdn.qikongjian.com/audio/1750385931_99a8fb42-af89-4ae9-841a-a49869f026bd_text_to_video_0.mp3',
},{
url: 'https://cdn.qikongjian.com/1751490803092_aamxzm.mp4',
script: 'Chen Haiqing blocked Director Wang\'s path and looked at him coldly.\nDirector Wang: Who are you?\nThe leader of the branch factory ran over and waved his hand, indicating that Director Wang should stop talking',
audio: 'https://cdn.qikongjian.com/audio/1750385931_99a8fb42-af89-4ae9-841a-a49869f026bd_text_to_video_0.mp3',
},{
url: 'https://cdn.qikongjian.com/1751490938703_otnf61.mp4',
script: 'Chen Haiqing: Wang Rixin from the finance department of the first refrigeration factory, is that you? You don\'t need to participate in the selection of outstanding cadres at the end of the year.',
audio: 'https://cdn.qikongjian.com/audio/1750385931_99a8fb42-af89-4ae9-841a-a49869f026bd_text_to_video_0.mp3',
},{
url: 'https://cdn.qikongjian.com/1751491225783_klohfj.mp4',
script: 'Chen Haiqing turned around and left, asking the leader of the nearby factory.\nChen Haiqing: What was the name of the female worker just now',
audio: 'https://cdn.qikongjian.com/audio/1750385931_99a8fb42-af89-4ae9-841a-a49869f026bd_text_to_video_0.mp3',
},{
url: 'https://cdn.qikongjian.com/1751491433385_8ewmia.mp4',
script: 'Factory leader: Her name is Cai Xiaoyan, a famous crooked woman in our factory! got divorced! There\'s also a son!\nChen Haiqing was taken aback and his footsteps stopped.',
audio: 'https://cdn.qikongjian.com/audio/1750385931_99a8fb42-af89-4ae9-841a-a49869f026bd_text_to_video_0.mp3',
},{
url: 'https://cdn.qikongjian.com/1751491627383_dfi3f2.mp4',
script: 'Chen Haiqing couldn\'t help but smile. Oh, so she\'s divorced',
audio: 'https://cdn.qikongjian.com/audio/1750385931_99a8fb42-af89-4ae9-841a-a49869f026bd_text_to_video_0.mp3',
}],
music: {
url: 'https://cdn.qikongjian.com/audio/1750385931_99a8fb42-af89-4ae9-841a-a49869f026bd_text_to_video_0.mp3',
script: '激昂的科幻背景音乐',
name: 'Music / sci-fi',
duration: '01m : 35s : 765ms',
totalDuration: '01m : 35s : 765ms',
isLooped: true,
},
final: {
url: 'https://cdn.qikongjian.com/1751491927911_ts5ckm.mp4'
},
}
]; ];
export const MOCK_SKETCH_SCRIPT = [ // 随机选择一组mock数据
'script-123', export const getRandomMockData = () => {
'script-123', const randomIndex = Math.floor(Math.random() * MOCK_DATA.length);
'script-123', return MOCK_DATA[randomIndex];
'script-123',
];
export const MOCK_VIDEO_URLS = [
'https://cdn.qikongjian.com/videos/1750385931_99a8fb42-af89-4ae9-841a-a49869f026bd_text_to_video_0.mp4',
'https://cdn.qikongjian.com/videos/1750389908_37d4fffa-8516-43a3-a423-fc0274f40e8a_text_to_video_0.mp4',
'https://cdn.qikongjian.com/videos/1750384661_d8e30b79-828e-48cd-9025-ab62a996717c_text_to_video_0.mp4',
'https://cdn.qikongjian.com/videos/1750320040_4b47996e-7c70-490e-8433-80c7df990fdd_text_to_video_0.mp4',
];
export const MOCK_SKETCH_COUNT = 8;
export const MOCK_FINAL_VIDEO = {
id: 'final-video',
url: 'https://cdn.qikongjian.com/videos/1750389908_37d4fffa-8516-43a3-a423-fc0274f40e8a_text_to_video_0.mp4',
thumbnail: 'https://d3phaj0sisr2ct.cloudfront.net/app/gen4/object-reference/welcome-ref-1.jpg',
}; };
export interface TaskObject { export interface TaskObject {
@ -43,6 +235,7 @@ export interface SketchItem {
id: string; id: string;
url: string; url: string;
script: string; script: string;
bg_rgb?: string[];
status: string; status: string;
} }
@ -53,14 +246,19 @@ export interface VideoItem {
status: string; status: string;
} }
export interface FinalVideo {
url: string;
}
export const STEP_MESSAGES = { export const STEP_MESSAGES = {
loading: '正在加载任务信息...', loading: 'Loading task information...',
sketch: (count: number, total: number) => `正在生成分镜草图 ${count + 1}/${total}...`, sketch: (count: number, total: number) => `Generating sketch ${count + 1 > total ? total : count + 1}/${total}...`,
sketchComplete: '分镜草图生成完成', sketchComplete: 'Sketch generation complete',
character: '正在绘制角色...', character: 'Drawing characters...',
video: (count: number, total: number) => `正在生成分镜视频 ${count + 1}/${total}...`, video: (count: number, total: number) => `Generating video ${count + 1 > total ? total : count + 1}/${total}...`,
videoComplete: '分镜视频生成完成', videoComplete: 'Video generation complete',
audio: '正在生成背景音...', audio: 'Generating background audio...',
final: '正在生成最终成品...', postProduction: (step: string) => `Post-production: ${step}...`,
complete: '任务完成' final: 'Generating final product...',
complete: 'Task completed'
}; };

View File

@ -1,5 +1,5 @@
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { TaskObject, SketchItem, VideoItem, STEP_MESSAGES, MOCK_SKETCH_COUNT } from '@/components/work-flow/constants'; import { TaskObject, SketchItem, VideoItem, STEP_MESSAGES } from '@/components/work-flow/constants';
import { import {
getTaskDetail, getTaskDetail,
getTaskSketch, getTaskSketch,
@ -17,7 +17,8 @@ export const useWorkFlow = () => {
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [currentStep, setCurrentStep] = useState('0'); const [currentStep, setCurrentStep] = useState('0');
const [currentSketchIndex, setCurrentSketchIndex] = useState(0); const [currentSketchIndex, setCurrentSketchIndex] = useState(0);
const [currentLoadingText, setCurrentLoadingText] = useState('加载中...'); const [currentLoadingText, setCurrentLoadingText] = useState('Loading task information...');
const [totalSketchCount, setTotalSketchCount] = useState(0);
// 生成状态 // 生成状态
const [isGeneratingSketch, setIsGeneratingSketch] = useState(false); const [isGeneratingSketch, setIsGeneratingSketch] = useState(false);
@ -107,13 +108,18 @@ export const useWorkFlow = () => {
if (prev.find(sketch => sketch.id === newSketch.id)) { if (prev.find(sketch => sketch.id === newSketch.id)) {
return prev; return prev;
} }
return [...prev, newSketch]; const newSketchList = [...prev, newSketch];
if (index === 0) {
// 这里我们需要从API获取总数暂时使用当前逻辑
}
return newSketchList;
}); });
setCurrentSketchIndex(index); setCurrentSketchIndex(index);
setSketchCount(index + 1); setSketchCount(index + 1);
}); });
setIsGeneratingSketch(false); setIsGeneratingSketch(false);
setTotalSketchCount(taskSketch.length);
}; };
const handleGetTaskVideo = async (taskId: string) => { const handleGetTaskVideo = async (taskId: string) => {
@ -199,7 +205,7 @@ export const useWorkFlow = () => {
case '1': case '1':
setCurrentLoadingText( setCurrentLoadingText(
isGeneratingSketch isGeneratingSketch
? STEP_MESSAGES.sketch(sketchCount, MOCK_SKETCH_COUNT) ? STEP_MESSAGES.sketch(sketchCount, Math.max(totalSketchCount, sketchCount + 1))
: STEP_MESSAGES.sketchComplete : STEP_MESSAGES.sketchComplete
); );
break; break;
@ -222,7 +228,7 @@ export const useWorkFlow = () => {
default: default:
setCurrentLoadingText(STEP_MESSAGES.complete); setCurrentLoadingText(STEP_MESSAGES.complete);
} }
}, [isLoading, currentStep, isGeneratingSketch, sketchCount, isGeneratingVideo, taskVideos.length, taskSketch.length]); }, [isLoading, currentStep, isGeneratingSketch, sketchCount, isGeneratingVideo, taskVideos.length, taskSketch.length, totalSketchCount]);
// 控制函数 // 控制函数
const togglePlay = useCallback(() => { const togglePlay = useCallback(() => {

View File

@ -3,7 +3,34 @@ const nextConfig = {
eslint: { eslint: {
ignoreDuringBuilds: true, ignoreDuringBuilds: true,
}, },
images: { unoptimized: true }, images: {
unoptimized: true
},
webpack: (config, { dev, isServer }) => {
if (dev && !isServer) {
// 开发环境优化
config.optimization.splitChunks = {
chunks: 'all',
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
priority: 20,
},
default: {
chunks: 'async',
minChunks: 2,
priority: 10,
reuseExistingChunk: true,
},
},
};
}
return config;
},
async rewrites() { async rewrites() {
return [ return [
{ {

1045
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -52,6 +52,7 @@
"antd": "^5.26.2", "antd": "^5.26.2",
"autoprefixer": "10.4.15", "autoprefixer": "10.4.15",
"axios": "^1.10.0", "axios": "^1.10.0",
"babel-loader": "^10.0.0",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.0.0", "cmdk": "^1.0.0",

152
utils/dev-helper.ts Normal file
View File

@ -0,0 +1,152 @@
/**
*
* Next.js
*/
/**
*
*/
export const isDevelopment = process.env.NODE_ENV === 'development';
/**
*
*
*/
export const safeRerender = (callback: () => void) => {
if (isDevelopment) {
try {
callback();
} catch (error) {
console.warn('热重载时组件重渲染失败:', error);
// 在开发环境中,可以选择刷新页面
if (window.confirm('检测到热重载错误,是否刷新页面?')) {
window.location.reload();
}
}
} else {
callback();
}
};
/**
* ChunkLoadError
*/
export const handleChunkLoadError = (error: Error): boolean => {
if (error.name === 'ChunkLoadError' || error.message.includes('Loading chunk')) {
console.warn('检测到 ChunkLoadError尝试刷新页面');
window.location.reload();
return true;
}
return false;
};
/**
*
*
*/
export const setupGlobalErrorHandler = () => {
if (isDevelopment && typeof window !== 'undefined') {
window.addEventListener('error', (event) => {
if (handleChunkLoadError(event.error)) {
event.preventDefault();
}
});
window.addEventListener('unhandledrejection', (event) => {
if (event.reason?.name === 'ChunkLoadError') {
console.warn('检测到未处理的 ChunkLoadError Promise 拒绝');
event.preventDefault();
window.location.reload();
}
});
}
};
/**
* Hook
*
*/
export const useSafeState = <T>(initialState: T): [T, (newState: T | ((prev: T) => T)) => void] => {
const [state, setState] = React.useState(initialState);
const mountedRef = React.useRef(true);
React.useEffect(() => {
return () => {
mountedRef.current = false;
};
}, []);
const safeSetState = React.useCallback((newState: T | ((prev: T) => T)) => {
if (mountedRef.current) {
setState(newState);
}
}, []);
return [state, safeSetState];
};
/**
*
*/
export const clearLocalCache = () => {
if (typeof window !== 'undefined') {
// 清理 localStorage
try {
const keys = Object.keys(localStorage);
keys.forEach(key => {
if (key.startsWith('__next') || key.startsWith('webpack')) {
localStorage.removeItem(key);
}
});
} catch (error) {
console.warn('清理 localStorage 失败:', error);
}
// 清理 sessionStorage
try {
const keys = Object.keys(sessionStorage);
keys.forEach(key => {
if (key.startsWith('__next') || key.startsWith('webpack')) {
sessionStorage.removeItem(key);
}
});
} catch (error) {
console.warn('清理 sessionStorage 失败:', error);
}
}
};
/**
*
*
*/
export const initDevHelper = () => {
if (isDevelopment) {
setupGlobalErrorHandler();
console.log('开发环境助手已启动');
// 在控制台提供一些有用的方法
if (typeof window !== 'undefined') {
(window as any).__devHelper = {
clearCache: clearLocalCache,
reload: () => window.location.reload(),
clearAndReload: () => {
clearLocalCache();
window.location.reload();
}
};
console.log('可用的开发工具方法:');
console.log('- __devHelper.clearCache(): 清理本地缓存');
console.log('- __devHelper.reload(): 刷新页面');
console.log('- __devHelper.clearAndReload(): 清理缓存并刷新页面');
}
}
};
// 自动初始化(仅在浏览器环境中)
if (typeof window !== 'undefined') {
initDevHelper();
}
import React from 'react';