forked from 77media/video-flow
工作流
This commit is contained in:
parent
72f7016680
commit
96a319240d
@ -97,7 +97,7 @@ body {
|
||||
}
|
||||
|
||||
.hide-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
@ -10,6 +10,12 @@ const OAuthCallbackHandler = dynamic(
|
||||
{ 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 = {
|
||||
title: 'AI Movie Flow - Create Amazing Videos with AI',
|
||||
description: 'Professional AI-powered video creation platform with advanced editing tools',
|
||||
@ -32,6 +38,7 @@ export default function RootLayout({
|
||||
{children}
|
||||
<Toaster />
|
||||
<OAuthCallbackHandler />
|
||||
{process.env.NODE_ENV === 'development' && <DevHelper />}
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -88,7 +88,7 @@ export function AISuggestionBar({
|
||||
</div>
|
||||
|
||||
<div className="max-w-5xl mx-auto px-6">
|
||||
{/* 智能预设词条 */}
|
||||
{/* 智能预设词条 英文 */}
|
||||
<AnimatePresence>
|
||||
{showSuggestions && !isCollapsed && (
|
||||
<motion.div
|
||||
@ -120,7 +120,7 @@ export function AISuggestionBar({
|
||||
>
|
||||
<Lightbulb className="w-4 h-4 text-yellow-500" />
|
||||
</motion.div>
|
||||
<span className="text-sm text-white/60">智能预设词条</span>
|
||||
<span className="text-sm text-white/60">Smart preset tags</span>
|
||||
</motion.div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{suggestions.map((suggestion, index) => (
|
||||
|
||||
@ -34,7 +34,7 @@
|
||||
color: var(--text-primary);
|
||||
text-align: center;
|
||||
letter-spacing: -.32px;
|
||||
font-size: 32px;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
line-height: 36px;
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import "./style/work-flow.css";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { AISuggestionBar } from "@/components/ai-suggestion-bar";
|
||||
import { EditModal } from "@/components/ui/edit-modal";
|
||||
import { ErrorBoundary } from "@/components/ui/error-boundary";
|
||||
import { TaskInfo } from "./work-flow/task-info";
|
||||
import { MediaViewer } from "./work-flow/media-viewer";
|
||||
import { ThumbnailGrid } from "./work-flow/thumbnail-grid";
|
||||
@ -27,6 +28,10 @@ export default function WorkFlow() {
|
||||
isGeneratingSketch,
|
||||
isGeneratingVideo,
|
||||
currentLoadingText,
|
||||
totalSketchCount,
|
||||
roles,
|
||||
music,
|
||||
final,
|
||||
setCurrentSketchIndex,
|
||||
} = useWorkflowData();
|
||||
|
||||
@ -35,29 +40,97 @@ export default function WorkFlow() {
|
||||
isVideoPlaying,
|
||||
showControls,
|
||||
setShowControls,
|
||||
setIsPlaying,
|
||||
togglePlay,
|
||||
toggleVideoPlay,
|
||||
playTimerRef,
|
||||
} = 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(() => {
|
||||
if (isPlaying && taskSketch.length > 0 && playTimerRef.current) {
|
||||
if (isPlaying && taskSketch.length > 0) {
|
||||
console.log('开始自动切换分镜,总数:', taskSketch.length);
|
||||
const interval = setInterval(() => {
|
||||
setCurrentSketchIndex((prev: number) => (prev + 1) % taskSketch.length);
|
||||
}, 2000);
|
||||
setCurrentSketchIndex((prev: number) => {
|
||||
const nextIndex = (prev + 1) % taskSketch.length;
|
||||
return nextIndex;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}
|
||||
}, [isPlaying, taskSketch.length, setCurrentSketchIndex]);
|
||||
|
||||
// 模拟 AI 建议
|
||||
// 模拟 AI 建议 英文
|
||||
const mockSuggestions = [
|
||||
"优化场景转场效果",
|
||||
"调整画面构图",
|
||||
"改进角色动作设计",
|
||||
"增加环境氛围",
|
||||
"调整镜头语言"
|
||||
"Refine scene transitions",
|
||||
"Adjust scene composition",
|
||||
"Improve character action design",
|
||||
"Add environmental atmosphere",
|
||||
"Adjust lens language"
|
||||
];
|
||||
|
||||
const handleEditModalOpen = (tab: string) => {
|
||||
@ -74,17 +147,20 @@ export default function WorkFlow() {
|
||||
};
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<div className="w-full h-full overflow-hidden">
|
||||
<div className="flex h-full flex-col p-6 justify-center items-center pt-0">
|
||||
<div className="container-H2sRZG">
|
||||
<div className="splashContainer-otuV_A">
|
||||
<div className="content-vPGYx8">
|
||||
<div className="info-UUGkPJ">
|
||||
<ErrorBoundary>
|
||||
<TaskInfo
|
||||
isLoading={isLoading}
|
||||
taskObject={taskObject}
|
||||
currentLoadingText={currentLoadingText}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
<div className="media-Ocdu1O">
|
||||
@ -97,6 +173,7 @@ export default function WorkFlow() {
|
||||
<Skeleton className="w-full aspect-video rounded-lg" />
|
||||
) : (
|
||||
<div className="heroVideo-FIzuK1" style={{ aspectRatio: "16 / 9" }}>
|
||||
<ErrorBoundary>
|
||||
<MediaViewer
|
||||
currentStep={currentStep}
|
||||
currentSketchIndex={currentSketchIndex}
|
||||
@ -105,15 +182,20 @@ export default function WorkFlow() {
|
||||
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 hidden-scrollbar">
|
||||
<div className="imageGrid-ymZV9z hide-scrollbar">
|
||||
<ErrorBoundary>
|
||||
<ThumbnailGrid
|
||||
isLoading={isLoading}
|
||||
currentStep={currentStep}
|
||||
@ -121,9 +203,12 @@ export default function WorkFlow() {
|
||||
taskSketch={taskSketch}
|
||||
taskVideos={taskVideos}
|
||||
isGeneratingSketch={isGeneratingSketch}
|
||||
isGeneratingVideo={isGeneratingVideo}
|
||||
sketchCount={sketchCount}
|
||||
totalSketchCount={totalSketchCount}
|
||||
onSketchSelect={setCurrentSketchIndex}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -131,13 +216,16 @@ export default function WorkFlow() {
|
||||
</div>
|
||||
|
||||
{/* AI 建议栏 */}
|
||||
<ErrorBoundary>
|
||||
<AISuggestionBar
|
||||
suggestions={mockSuggestions}
|
||||
onSuggestionClick={handleSuggestionClick}
|
||||
onSubmit={handleSubmit}
|
||||
placeholder="请输入你的想法,或点击预设词条获取 AI 建议..."
|
||||
placeholder="Please input your ideas, or click the predefined tags to receive AI advice..."
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
|
||||
<ErrorBoundary>
|
||||
<EditModal
|
||||
isOpen={isEditModalOpen}
|
||||
activeEditTab={activeEditTab}
|
||||
@ -147,7 +235,11 @@ export default function WorkFlow() {
|
||||
sketchVideo={taskVideos}
|
||||
currentSketchIndex={currentSketchIndex}
|
||||
onSketchSelect={setCurrentSketchIndex}
|
||||
roles={roles}
|
||||
music={music}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
@ -14,18 +14,15 @@ interface MediaViewerProps {
|
||||
isVideoPlaying: boolean;
|
||||
isPlaying: boolean;
|
||||
showControls: boolean;
|
||||
isGeneratingSketch: boolean;
|
||||
isGeneratingVideo: boolean;
|
||||
onControlsChange: (show: boolean) => void;
|
||||
onEditModalOpen: (tab: string) => void;
|
||||
onToggleVideoPlay: () => 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({
|
||||
currentStep,
|
||||
currentSketchIndex,
|
||||
@ -34,10 +31,13 @@ export function MediaViewer({
|
||||
isVideoPlaying,
|
||||
isPlaying,
|
||||
showControls,
|
||||
isGeneratingSketch,
|
||||
isGeneratingVideo,
|
||||
onControlsChange,
|
||||
onEditModalOpen,
|
||||
onToggleVideoPlay,
|
||||
onTogglePlay
|
||||
onTogglePlay,
|
||||
final
|
||||
}: MediaViewerProps) {
|
||||
const mainVideoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
@ -67,7 +67,13 @@ export function MediaViewer({
|
||||
}, [currentSketchIndex]);
|
||||
|
||||
// 渲染最终成片
|
||||
const renderFinalVideo = () => (
|
||||
const renderFinalVideo = () => {
|
||||
// 使用真实的final数据,如果没有则使用默认值
|
||||
const finalVideo = final || {
|
||||
url: 'https://cdn.qikongjian.com/videos/1750389908_37d4fffa-8516-43a3-a423-fc0274f40e8a_text_to_video_0.mp4'
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative w-full h-full rounded-lg overflow-hidden"
|
||||
onMouseEnter={() => onControlsChange(true)}
|
||||
@ -86,7 +92,6 @@ export function MediaViewer({
|
||||
src={taskVideos[currentSketchIndex]?.url}
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
/>
|
||||
</motion.div>
|
||||
@ -103,11 +108,10 @@ export function MediaViewer({
|
||||
>
|
||||
<video
|
||||
className="w-full h-full object-cover rounded-lg"
|
||||
src={MOCK_FINAL_VIDEO.url}
|
||||
poster={MOCK_FINAL_VIDEO.thumbnail}
|
||||
src={finalVideo.url}
|
||||
poster={taskSketch[currentSketchIndex]?.url}
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
/>
|
||||
</motion.div>
|
||||
@ -124,7 +128,7 @@ export function MediaViewer({
|
||||
>
|
||||
<GlassIconButton
|
||||
icon={Edit3}
|
||||
tooltip="编辑分镜"
|
||||
tooltip="Edit sketch"
|
||||
onClick={() => onEditModalOpen('4')}
|
||||
/>
|
||||
</motion.div>
|
||||
@ -152,7 +156,7 @@ export function MediaViewer({
|
||||
ease: "easeInOut"
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm font-medium text-white/90">最终成片</span>
|
||||
<span className="text-sm font-medium text-white/90">Final product</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
@ -165,41 +169,63 @@ export function MediaViewer({
|
||||
animate={{ opacity: 1, scale: 1, x: 0 }}
|
||||
transition={{ delay: 1.2, duration: 0.6 }}
|
||||
>
|
||||
制作完成
|
||||
Task completed
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染视频内容
|
||||
const renderVideoContent = () => (
|
||||
const renderVideoContent = () => {
|
||||
const currentSketch = taskSketch[currentSketchIndex];
|
||||
const defaultBgColors = ['RGB(45, 50, 70)', 'RGB(75, 80, 100)', 'RGB(105, 110, 130)'];
|
||||
const bgColors = currentSketch?.bg_rgb || defaultBgColors;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative w-full h-full rounded-lg"
|
||||
onMouseEnter={() => onControlsChange(true)}
|
||||
onMouseLeave={() => onControlsChange(false)}
|
||||
>
|
||||
{taskVideos[currentSketchIndex] ? (
|
||||
{/* 只在生成过程中或没有视频时使用ProgressiveReveal */}
|
||||
{(isGeneratingVideo || !taskVideos[currentSketchIndex]) ? (
|
||||
taskVideos[currentSketchIndex] ? (
|
||||
<ProgressiveReveal
|
||||
key={`generte-video-${currentSketchIndex}`}
|
||||
className="w-full h-full rounded-lg"
|
||||
revealDuration={0.8}
|
||||
blurDuration={0.3}
|
||||
initialBlur={10}
|
||||
customVariants={{
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
filter: "blur(20px)",
|
||||
scale: 0.9,
|
||||
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,
|
||||
ease: [0.43, 0.13, 0.23, 0.96],
|
||||
duration: 1.5,
|
||||
ease: [0.23, 1, 0.32, 1],
|
||||
opacity: { duration: 0.8, ease: "easeOut" },
|
||||
filter: { duration: 0.6, ease: "easeOut" },
|
||||
clipPath: { duration: 0.8, ease: "easeInOut" }
|
||||
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">
|
||||
{/* 背景模糊的图片 */}
|
||||
@ -224,10 +250,8 @@ export function MediaViewer({
|
||||
className="w-full h-full rounded-lg object-cover object-center"
|
||||
src={taskVideos[currentSketchIndex].url}
|
||||
autoPlay={isVideoPlaying}
|
||||
muted
|
||||
loop={false}
|
||||
loop={true}
|
||||
playsInline
|
||||
poster={taskSketch[currentSketchIndex]?.url}
|
||||
onEnded={() => {
|
||||
if (isVideoPlaying) {
|
||||
// 自动切换到下一个视频的逻辑在父组件处理
|
||||
@ -242,7 +266,36 @@ export function MediaViewer({
|
||||
<img
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
src={taskSketch[currentSketchIndex]?.url}
|
||||
alt={`分镜草图 ${currentSketchIndex + 1}`}
|
||||
alt={`Sketch ${currentSketchIndex + 1}`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
/* 生成完成后直接显示视频,不使用ProgressiveReveal */
|
||||
<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>
|
||||
|
||||
{/* 视频 修复播放没有声音 */}
|
||||
<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>
|
||||
)}
|
||||
@ -251,7 +304,7 @@ export function MediaViewer({
|
||||
<AnimatePresence>
|
||||
{showControls && (
|
||||
<motion.div
|
||||
className="absolute top-4 right-4 flex gap-2"
|
||||
className="absolute top-4 right-4 flex gap-2 z-[11]"
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
@ -259,7 +312,7 @@ export function MediaViewer({
|
||||
>
|
||||
<GlassIconButton
|
||||
icon={Edit3}
|
||||
tooltip="编辑分镜"
|
||||
tooltip="Edit sketch"
|
||||
onClick={() => onEditModalOpen('3')}
|
||||
/>
|
||||
</motion.div>
|
||||
@ -269,7 +322,7 @@ export function MediaViewer({
|
||||
{/* 底部播放按钮 */}
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
className="absolute bottom-4 left-4"
|
||||
className="absolute bottom-4 left-4 z-[11]"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
@ -278,10 +331,26 @@ export function MediaViewer({
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
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 ? "暂停播放" : "自动播放"}
|
||||
tooltip={isVideoPlaying ? "Pause video" : "Play video"}
|
||||
onClick={onToggleVideoPlay}
|
||||
size="sm"
|
||||
/>
|
||||
@ -290,23 +359,60 @@ export function MediaViewer({
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染分镜草图
|
||||
const renderSketchContent = () => (
|
||||
const renderSketchContent = () => {
|
||||
const currentSketch = taskSketch[currentSketchIndex];
|
||||
const defaultBgColors = ['RGB(45, 50, 70)', 'RGB(75, 80, 100)', 'RGB(105, 110, 130)'];
|
||||
const bgColors = currentSketch?.bg_rgb || defaultBgColors;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative w-full h-full rounded-lg"
|
||||
onMouseEnter={() => onControlsChange(true)}
|
||||
onMouseLeave={() => onControlsChange(false)}
|
||||
>
|
||||
{taskSketch[currentSketchIndex] ? (
|
||||
{/* 只在生成过程中或没有分镜图片时使用ProgressiveReveal */}
|
||||
{(isGeneratingSketch || !currentSketch) ? (
|
||||
currentSketch ? (
|
||||
<ProgressiveReveal
|
||||
key={`sketch-generating-${currentSketchIndex}`}
|
||||
className="w-full h-full rounded-lg"
|
||||
{...presets.main}
|
||||
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={taskSketch[currentSketchIndex].url}
|
||||
alt={`分镜草图 ${currentSketchIndex + 1}`}
|
||||
src={currentSketch.url}
|
||||
alt={`Sketch ${currentSketchIndex + 1}`}
|
||||
className="w-full h-full rounded-lg object-cover"
|
||||
/>
|
||||
</ProgressiveReveal>
|
||||
@ -314,7 +420,7 @@ export function MediaViewer({
|
||||
<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"
|
||||
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%"],
|
||||
}}
|
||||
@ -364,6 +470,16 @@ export function MediaViewer({
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
|
||||
) : (
|
||||
/* 生成完成后直接显示图片,不使用ProgressiveReveal */
|
||||
<img
|
||||
key={currentSketchIndex}
|
||||
src={currentSketch.url}
|
||||
alt={`Sketch ${currentSketchIndex + 1}`}
|
||||
className="w-full h-full rounded-lg object-cover"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 操作按钮组 */}
|
||||
@ -378,7 +494,7 @@ export function MediaViewer({
|
||||
>
|
||||
<GlassIconButton
|
||||
icon={Edit3}
|
||||
tooltip="编辑分镜"
|
||||
tooltip="Edit sketch"
|
||||
onClick={() => onEditModalOpen('1')}
|
||||
/>
|
||||
</motion.div>
|
||||
@ -397,12 +513,29 @@ export function MediaViewer({
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
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 ? "暂停播放" : "自动播放"}
|
||||
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>
|
||||
@ -423,6 +556,7 @@ export function MediaViewer({
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 根据当前步骤渲染对应内容
|
||||
if (Number(currentStep) === 6) {
|
||||
|
||||
@ -26,7 +26,7 @@ export function TaskInfo({ isLoading, taskObject, currentLoadingText }: TaskInfo
|
||||
{taskObject?.projectName}:{taskObject?.taskName}
|
||||
</div>
|
||||
|
||||
{currentLoadingText === '任务完成' ? (
|
||||
{currentLoadingText === 'Task completed' ? (
|
||||
<motion.div
|
||||
className="flex items-center gap-3 justify-center"
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
@ -98,16 +98,93 @@ export function TaskInfo({ isLoading, taskObject, currentLoadingText }: TaskInfo
|
||||
repeatDelay: 0.2
|
||||
}}
|
||||
/>
|
||||
<motion.p
|
||||
className="normalS400 subtitle-had8uE text-blue-500/80"
|
||||
<motion.div
|
||||
className="relative"
|
||||
key={currentLoadingText}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 10 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{/* 背景发光效果 */}
|
||||
<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.p>
|
||||
</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
|
||||
className="w-1.5 h-1.5 rounded-full bg-blue-500"
|
||||
animate={{
|
||||
|
||||
@ -12,12 +12,12 @@ interface ThumbnailGridProps {
|
||||
taskSketch: any[];
|
||||
taskVideos: any[];
|
||||
isGeneratingSketch: boolean;
|
||||
isGeneratingVideo: boolean;
|
||||
sketchCount: number;
|
||||
totalSketchCount: number;
|
||||
onSketchSelect: (index: number) => void;
|
||||
}
|
||||
|
||||
const MOCK_SKETCH_COUNT = 8;
|
||||
|
||||
export function ThumbnailGrid({
|
||||
isLoading,
|
||||
currentStep,
|
||||
@ -25,7 +25,9 @@ export function ThumbnailGrid({
|
||||
taskSketch,
|
||||
taskVideos,
|
||||
isGeneratingSketch,
|
||||
isGeneratingVideo,
|
||||
sketchCount,
|
||||
totalSketchCount,
|
||||
onSketchSelect
|
||||
}: ThumbnailGridProps) {
|
||||
const thumbnailsRef = useRef<HTMLDivElement>(null);
|
||||
@ -99,7 +101,12 @@ export function ThumbnailGrid({
|
||||
}
|
||||
|
||||
// 渲染生成中的缩略图
|
||||
const renderGeneratingThumbnail = () => (
|
||||
const renderGeneratingThumbnail = () => {
|
||||
const currentSketch = taskSketch[currentSketchIndex];
|
||||
const defaultBgColors = ['RGB(45, 50, 70)', 'RGB(75, 80, 100)', 'RGB(105, 110, 130)'];
|
||||
const bgColors = currentSketch?.bg_rgb || defaultBgColors;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="relative aspect-video rounded-lg overflow-hidden"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
@ -108,7 +115,7 @@ export function ThumbnailGrid({
|
||||
>
|
||||
{/* 动态渐变背景 */}
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-gradient-to-r from-cyan-300 via-sky-400 to-blue-500"
|
||||
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%"],
|
||||
}}
|
||||
@ -153,23 +160,44 @@ export function ThumbnailGrid({
|
||||
</div>
|
||||
</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">场景 {sketchCount + 1}</span>
|
||||
<span className="text-xs text-white/90">Scene {sketchCount + 1}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染视频阶段的缩略图
|
||||
const renderVideoThumbnails = () => (
|
||||
taskSketch.map((sketch, index) => (
|
||||
taskSketch.map((sketch, index) => {
|
||||
const defaultBgColors = ['RGB(45, 50, 70)', 'RGB(75, 80, 100)', 'RGB(105, 110, 130)'];
|
||||
const bgColors = sketch?.bg_rgb || defaultBgColors;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`video-${index}`}
|
||||
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'}`}
|
||||
onClick={() => !isDragging && onSketchSelect(index)}
|
||||
>
|
||||
{/* 底层草图,始终显示 */}
|
||||
<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动画显示 */}
|
||||
{taskVideos[index] && (
|
||||
<div className="absolute inset-0">
|
||||
{isGeneratingVideo ? (
|
||||
<ProgressiveReveal
|
||||
{...presets.thumbnail}
|
||||
delay={index * 0.1}
|
||||
key={`video-thumbnail-generating-${index}`}
|
||||
revealDuration={0.8}
|
||||
blurDuration={0.3}
|
||||
initialBlur={10}
|
||||
delay={index === currentSketchIndex ? 0 : index * 0.1}
|
||||
customVariants={{
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
@ -190,50 +218,68 @@ export function ThumbnailGrid({
|
||||
}
|
||||
}}
|
||||
loadingBgConfig={{
|
||||
...presets.thumbnail.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">
|
||||
{taskVideos[index] ? (
|
||||
<video
|
||||
className="w-full h-full object-cover"
|
||||
src={taskVideos[index].url}
|
||||
muted
|
||||
playsInline
|
||||
loop
|
||||
poster={sketch.url}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
className="w-full h-full object-cover"
|
||||
src={sketch.url}
|
||||
alt={`缩略图 ${index + 1}`}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
) : (
|
||||
/* 生成完成后直接显示视频,不使用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>
|
||||
))
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
// 渲染分镜草图阶段的缩略图
|
||||
const renderSketchThumbnails = () => (
|
||||
<>
|
||||
{taskSketch.map((sketch, index) => (
|
||||
{taskSketch.map((sketch, index) => {
|
||||
const defaultBgColors = ['RGB(45, 50, 70)', 'RGB(75, 80, 100)', 'RGB(105, 110, 130)'];
|
||||
const bgColors = sketch?.bg_rgb || defaultBgColors;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`sketch-${index}`}
|
||||
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'}`}
|
||||
onClick={() => !isDragging && onSketchSelect(index)}
|
||||
>
|
||||
{/* 只在生成过程中或没有分镜图片时使用ProgressiveReveal */}
|
||||
{(isGeneratingSketch || !sketch) ? (
|
||||
<ProgressiveReveal
|
||||
{...presets.thumbnail}
|
||||
delay={index * 0.1}
|
||||
key={`sketch-thumbnail-generating-${index}`}
|
||||
revealDuration={0.8}
|
||||
blurDuration={0.3}
|
||||
initialBlur={10}
|
||||
delay={index === currentSketchIndex ? 0 : index * 0.1}
|
||||
customVariants={{
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
@ -246,7 +292,7 @@ export function ThumbnailGrid({
|
||||
filter: "blur(0px)",
|
||||
transition: {
|
||||
duration: 0.8,
|
||||
ease: [0.23, 1, 0.32, 1], // cubic-bezier
|
||||
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 }
|
||||
@ -254,7 +300,9 @@ export function ThumbnailGrid({
|
||||
}
|
||||
}}
|
||||
loadingBgConfig={{
|
||||
...presets.thumbnail.loadingBgConfig,
|
||||
fromColor: `from-[${bgColors[0]}]`,
|
||||
viaColor: `via-[${bgColors[1]}]`,
|
||||
toColor: `to-[${bgColors[2]}]`,
|
||||
glowOpacity: 0.4,
|
||||
duration: 4
|
||||
}}
|
||||
@ -263,23 +311,34 @@ export function ThumbnailGrid({
|
||||
<img
|
||||
className="w-full h-full object-cover"
|
||||
src={sketch.url}
|
||||
alt={`缩略图 ${index + 1}`}
|
||||
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">场景 {index + 1}</span>
|
||||
<span className="text-xs text-white/90">Scene {index + 1}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{isGeneratingSketch && sketchCount < MOCK_SKETCH_COUNT && renderGeneratingThumbnail()}
|
||||
);
|
||||
})}
|
||||
{isGeneratingSketch && sketchCount < totalSketchCount && renderGeneratingThumbnail()}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
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}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
|
||||
@ -19,23 +19,23 @@ export function usePlaybackControls(taskSketch: any[], taskVideos: any[], curren
|
||||
setIsVideoPlaying(prev => !prev);
|
||||
}, []);
|
||||
|
||||
// 自动播放逻辑 - 分镜草图
|
||||
useEffect(() => {
|
||||
if (isPlaying && taskSketch.length > 0) {
|
||||
playTimerRef.current = setInterval(() => {
|
||||
// 这里的切换逻辑需要在父组件中处理
|
||||
// 因为需要访问 setCurrentSketchIndex
|
||||
}, 2000);
|
||||
} else if (playTimerRef.current) {
|
||||
clearInterval(playTimerRef.current);
|
||||
}
|
||||
// 自动播放逻辑 - 分镜草图(移除重复的定时器逻辑,由主组件处理)
|
||||
// useEffect(() => {
|
||||
// if (isPlaying && taskSketch.length > 0) {
|
||||
// playTimerRef.current = setInterval(() => {
|
||||
// // 这里的切换逻辑需要在父组件中处理
|
||||
// // 因为需要访问 setCurrentSketchIndex
|
||||
// }, 1000);
|
||||
// } else if (playTimerRef.current) {
|
||||
// clearInterval(playTimerRef.current);
|
||||
// }
|
||||
|
||||
return () => {
|
||||
if (playTimerRef.current) {
|
||||
clearInterval(playTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [isPlaying, taskSketch.length]);
|
||||
// return () => {
|
||||
// if (playTimerRef.current) {
|
||||
// clearInterval(playTimerRef.current);
|
||||
// }
|
||||
// };
|
||||
// }, [isPlaying, taskSketch.length]);
|
||||
|
||||
// 视频自动播放逻辑
|
||||
useEffect(() => {
|
||||
@ -55,12 +55,13 @@ export function usePlaybackControls(taskSketch: any[], taskVideos: any[], curren
|
||||
};
|
||||
}, [isVideoPlaying, taskVideos.length]);
|
||||
|
||||
// 当切换到视频模式时,停止播放
|
||||
useEffect(() => {
|
||||
if (currentStep === '3') {
|
||||
setIsPlaying(false);
|
||||
}
|
||||
}, [currentStep]);
|
||||
// 当切换到视频模式时,停止分镜草图播放(注释掉,让用户手动控制)
|
||||
// useEffect(() => {
|
||||
// if (Number(currentStep) >= 3) {
|
||||
// console.log('切换到步骤3+,停止分镜草图播放');
|
||||
// setIsPlaying(false);
|
||||
// }
|
||||
// }, [currentStep]);
|
||||
|
||||
// 当切换到分镜草图模式时,停止视频播放
|
||||
useEffect(() => {
|
||||
@ -74,6 +75,7 @@ export function usePlaybackControls(taskSketch: any[], taskVideos: any[], curren
|
||||
isVideoPlaying,
|
||||
showControls,
|
||||
setShowControls,
|
||||
setIsPlaying,
|
||||
togglePlay,
|
||||
toggleVideoPlay,
|
||||
playTimerRef, // 暴露给父组件使用
|
||||
|
||||
@ -1,29 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { getRandomMockData, STEP_MESSAGES } from '@/components/work-flow/constants';
|
||||
|
||||
const MOCK_SKETCH_URLS = [
|
||||
'https://d3phaj0sisr2ct.cloudfront.net/app/gen4/object-reference/welcome-ref-1.jpg',
|
||||
'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;
|
||||
// 当前选择的mock数据
|
||||
let selectedMockData = getRandomMockData();
|
||||
|
||||
export function useWorkflowData() {
|
||||
const [taskObject, setTaskObject] = useState<any>(null);
|
||||
@ -35,20 +16,23 @@ export function useWorkflowData() {
|
||||
const [currentSketchIndex, setCurrentSketchIndex] = useState(0);
|
||||
const [isGeneratingSketch, setIsGeneratingSketch] = useState(false);
|
||||
const [isGeneratingVideo, setIsGeneratingVideo] = useState(false);
|
||||
const [currentLoadingText, setCurrentLoadingText] = useState('加载中...');
|
||||
const [currentLoadingText, setCurrentLoadingText] = useState('Loading task information...');
|
||||
|
||||
// 模拟接口请求 获取任务详情
|
||||
const getTaskDetail = async (taskId: string) => {
|
||||
// 每次进入页面时重新随机选择数据
|
||||
selectedMockData = getRandomMockData();
|
||||
|
||||
const data = {
|
||||
projectId: 'projectId-123',
|
||||
projectName: "Project 1",
|
||||
projectId: selectedMockData.detail.projectId,
|
||||
projectName: selectedMockData.detail.projectName,
|
||||
taskId: taskId,
|
||||
taskName: "Task 1",
|
||||
taskDescription: "Task 1 Description",
|
||||
taskStatus: "1",
|
||||
taskName: selectedMockData.detail.taskName,
|
||||
taskDescription: selectedMockData.detail.taskDescription,
|
||||
taskStatus: selectedMockData.detail.taskStatus,
|
||||
taskProgress: 0,
|
||||
mode: 'auto',
|
||||
resolution: '1080p',
|
||||
mode: selectedMockData.detail.mode,
|
||||
resolution: selectedMockData.detail.resolution.toString(),
|
||||
};
|
||||
return data;
|
||||
};
|
||||
@ -60,14 +44,18 @@ export function useWorkflowData() {
|
||||
setIsGeneratingSketch(true);
|
||||
setTaskSketch([]);
|
||||
|
||||
const sketchData = selectedMockData.sketch;
|
||||
const totalSketches = sketchData.length;
|
||||
|
||||
// 模拟分批获取分镜草图
|
||||
for (let i = 0; i < MOCK_SKETCH_COUNT; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
for (let i = 0; i < totalSketches; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10000));
|
||||
|
||||
const newSketch = {
|
||||
id: `sketch-${i}`,
|
||||
url: MOCK_SKETCH_URLS[i % MOCK_SKETCH_URLS.length],
|
||||
script: MOCK_SKETCH_SCRIPT[i % MOCK_SKETCH_SCRIPT.length],
|
||||
url: sketchData[i].url,
|
||||
script: sketchData[i].script,
|
||||
bg_rgb: sketchData[i].bg_rgb,
|
||||
status: 'done'
|
||||
};
|
||||
|
||||
@ -81,22 +69,24 @@ export function useWorkflowData() {
|
||||
setSketchCount(i + 1);
|
||||
}
|
||||
|
||||
// 等待最后一个动画完成再设置生成状态为false
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
setIsGeneratingSketch(false);
|
||||
};
|
||||
|
||||
// 模拟接口请求 每次获取一个角色 轮询获取
|
||||
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) => {
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
await new Promise(resolve => setTimeout(resolve, 10000)); // 10s
|
||||
};
|
||||
|
||||
// 模拟接口请求 获取最终成品
|
||||
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);
|
||||
setTaskVideos([]);
|
||||
|
||||
const videoData = selectedMockData.video;
|
||||
const totalVideos = videoData.length;
|
||||
|
||||
// 模拟分批获取分镜视频
|
||||
for (let i = 0; i < MOCK_SKETCH_COUNT; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
for (let i = 0; i < totalVideos; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 60000)); // 60s
|
||||
|
||||
const newVideo = {
|
||||
id: `video-${i}`,
|
||||
url: MOCK_VIDEO_URLS[i % MOCK_VIDEO_URLS.length],
|
||||
script: MOCK_SKETCH_SCRIPT[i % MOCK_SKETCH_SCRIPT.length],
|
||||
url: videoData[i].url,
|
||||
script: videoData[i].script,
|
||||
status: 'done'
|
||||
};
|
||||
|
||||
@ -124,36 +117,40 @@ export function useWorkflowData() {
|
||||
setCurrentSketchIndex(i);
|
||||
}
|
||||
|
||||
// 等待最后一个动画完成再设置生成状态为false
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
setIsGeneratingVideo(false);
|
||||
};
|
||||
|
||||
// 更新加载文字
|
||||
useEffect(() => {
|
||||
if (isLoading) {
|
||||
setCurrentLoadingText('正在加载任务信息...');
|
||||
setCurrentLoadingText(STEP_MESSAGES.loading);
|
||||
return;
|
||||
}
|
||||
|
||||
const totalSketches = selectedMockData.sketch.length;
|
||||
|
||||
if (currentStep === '1') {
|
||||
if (isGeneratingSketch) {
|
||||
setCurrentLoadingText(`正在生成分镜草图 ${sketchCount + 1}/${MOCK_SKETCH_COUNT}...`);
|
||||
setCurrentLoadingText(STEP_MESSAGES.sketch(sketchCount, totalSketches));
|
||||
} else {
|
||||
setCurrentLoadingText('分镜草图生成完成');
|
||||
setCurrentLoadingText(STEP_MESSAGES.sketchComplete);
|
||||
}
|
||||
} else if (currentStep === '2') {
|
||||
setCurrentLoadingText('正在绘制角色...');
|
||||
setCurrentLoadingText(STEP_MESSAGES.character);
|
||||
} else if (currentStep === '3') {
|
||||
if (isGeneratingVideo) {
|
||||
setCurrentLoadingText(`正在生成分镜视频 ${taskVideos.length + 1}/${taskSketch.length}...`);
|
||||
setCurrentLoadingText(STEP_MESSAGES.video(taskVideos.length, totalSketches));
|
||||
} else {
|
||||
setCurrentLoadingText('分镜视频生成完成');
|
||||
setCurrentLoadingText(STEP_MESSAGES.videoComplete);
|
||||
}
|
||||
} else if (currentStep === '4') {
|
||||
setCurrentLoadingText('正在生成背景音...');
|
||||
setCurrentLoadingText(STEP_MESSAGES.audio);
|
||||
} else if (currentStep === '5') {
|
||||
setCurrentLoadingText('正在生成最终成品...');
|
||||
setCurrentLoadingText(STEP_MESSAGES.final);
|
||||
} else {
|
||||
setCurrentLoadingText('任务完成');
|
||||
setCurrentLoadingText(STEP_MESSAGES.complete);
|
||||
}
|
||||
}, [isLoading, currentStep, isGeneratingSketch, sketchCount, isGeneratingVideo, taskVideos.length, taskSketch.length]);
|
||||
|
||||
@ -167,7 +164,6 @@ export function useWorkflowData() {
|
||||
|
||||
// 只在任务详情加载完成后获取分镜草图
|
||||
await getTaskSketch(taskId);
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
// 修改 taskObject 下的 taskStatus 为 '2'
|
||||
setTaskObject((prev: any) => ({
|
||||
@ -178,7 +174,6 @@ export function useWorkflowData() {
|
||||
|
||||
// 获取分镜草图后,开始绘制角色
|
||||
await getTaskRole(taskId);
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
// 修改 taskObject 下的 taskStatus 为 '3'
|
||||
setTaskObject((prev: any) => ({
|
||||
@ -189,7 +184,6 @@ export function useWorkflowData() {
|
||||
|
||||
// 获取绘制角色后,开始获取分镜视频
|
||||
await getTaskVideo(taskId);
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
// 修改 taskObject 下的 taskStatus 为 '4'
|
||||
setTaskObject((prev: any) => ({
|
||||
@ -208,7 +202,15 @@ export function useWorkflowData() {
|
||||
taskStatus: '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 new Promise(resolve => setTimeout(resolve, 2000));
|
||||
@ -234,6 +236,10 @@ export function useWorkflowData() {
|
||||
isGeneratingSketch,
|
||||
isGeneratingVideo,
|
||||
currentLoadingText,
|
||||
totalSketchCount: selectedMockData.sketch.length,
|
||||
roles: selectedMockData.roles,
|
||||
music: selectedMockData.music,
|
||||
final: selectedMockData.final,
|
||||
// 操作方法
|
||||
setCurrentSketchIndex,
|
||||
};
|
||||
|
||||
@ -207,13 +207,10 @@ export function AudioVisualizer({
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-white">
|
||||
{title}
|
||||
{hasError && <span className="text-yellow-500 text-xs ml-2">(Demo)</span>}
|
||||
</div>
|
||||
<div className="text-xs text-white/60">Audio track</div>
|
||||
Audio & SFX
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-white/70">{volume}%</div>
|
||||
</div>
|
||||
|
||||
{/* 波形可视化 */}
|
||||
@ -293,26 +290,6 @@ export function AudioVisualizer({
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</span>
|
||||
</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>
|
||||
|
||||
{/* 播放状态指示器 */}
|
||||
@ -328,13 +305,6 @@ export function AudioVisualizer({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 错误提示 */}
|
||||
{hasError && (
|
||||
<div className="text-xs text-yellow-500/80 text-center">
|
||||
演示模式 - 使用模拟音频数据
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
@ -1,40 +1,30 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
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 { GlassIconButton } from './glass-icon-button';
|
||||
import { ReplaceCharacterModal } from './replace-character-modal';
|
||||
|
||||
interface Role {
|
||||
name: string;
|
||||
url: string;
|
||||
sound: string;
|
||||
soundDescription: string;
|
||||
roleDescription: string;
|
||||
}
|
||||
|
||||
interface CharacterTabContentProps {
|
||||
taskSketch: any[];
|
||||
currentSketchIndex: number;
|
||||
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({
|
||||
taskSketch,
|
||||
currentSketchIndex,
|
||||
onSketchSelect
|
||||
onSketchSelect,
|
||||
roles = []
|
||||
}: CharacterTabContentProps) {
|
||||
const [selectedCharacterIndex, setSelectedCharacterIndex] = useState(0);
|
||||
const [isReplaceModalOpen, setIsReplaceModalOpen] = useState(false);
|
||||
@ -47,6 +37,16 @@ export function CharacterTabContent({
|
||||
}>({ type: null, value: '' });
|
||||
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 = () => {
|
||||
if (audioRef.current) {
|
||||
@ -79,6 +79,9 @@ export function CharacterTabContent({
|
||||
}
|
||||
};
|
||||
|
||||
// 获取当前选中的角色
|
||||
const currentRole = roles[selectedCharacterIndex];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* 上部分:角色缩略图 */}
|
||||
@ -89,9 +92,9 @@ export function CharacterTabContent({
|
||||
>
|
||||
<div className="relative">
|
||||
<div className="flex gap-4 overflow-x-auto p-2 hide-scrollbar">
|
||||
{MOCK_CHARACTERS.map((character, index) => (
|
||||
{roles.map((role, index) => (
|
||||
<motion.div
|
||||
key={character.id}
|
||||
key={`role-${index}`}
|
||||
className={cn(
|
||||
'relative flex-shrink-0 w-24 rounded-lg overflow-hidden cursor-pointer',
|
||||
'aspect-[9/16]',
|
||||
@ -102,12 +105,12 @@ export function CharacterTabContent({
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<img
|
||||
src={character.avatar}
|
||||
alt={character.name}
|
||||
src={role.url}
|
||||
alt={role.name}
|
||||
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">
|
||||
<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>
|
||||
</motion.div>
|
||||
))}
|
||||
@ -122,7 +125,7 @@ export function CharacterTabContent({
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
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">
|
||||
<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"
|
||||
@ -134,7 +137,7 @@ export function CharacterTabContent({
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<Upload className="w-6 h-6" />
|
||||
<span>上传角色</span>
|
||||
<span>Upload character</span>
|
||||
</motion.button>
|
||||
|
||||
<motion.button
|
||||
@ -147,7 +150,7 @@ export function CharacterTabContent({
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<Library className="w-6 h-6" />
|
||||
<span>角色库</span>
|
||||
<span>Character library</span>
|
||||
</motion.button>
|
||||
</div>
|
||||
</motion.div>
|
||||
@ -163,10 +166,10 @@ export function CharacterTabContent({
|
||||
<div className="space-y-4">
|
||||
{/* 角色姓名 */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-white/70">角色姓名</label>
|
||||
<label className="text-sm text-white/70">Character name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={MOCK_CHARACTERS[selectedCharacterIndex].name}
|
||||
value={currentRole.name}
|
||||
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
|
||||
focus:outline-none focus:border-blue-500"
|
||||
@ -174,9 +177,9 @@ export function CharacterTabContent({
|
||||
</div>
|
||||
{/* 声音描述 */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-white/70">声音描述</label>
|
||||
<label className="text-sm text-white/70">Voice description</label>
|
||||
<textarea
|
||||
value={MOCK_CHARACTERS[selectedCharacterIndex].voiceDescription}
|
||||
value={currentRole.soundDescription}
|
||||
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
|
||||
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="flex items-center justify-between">
|
||||
<span className="text-sm text-white/70">声音预览</span>
|
||||
<span className="text-sm text-white/70">Voice preview</span>
|
||||
<GlassIconButton
|
||||
icon={RefreshCw}
|
||||
tooltip="重新生成声音"
|
||||
tooltip="Regenerate voice"
|
||||
onClick={() => console.log('regenerate voice')}
|
||||
size="sm"
|
||||
/>
|
||||
@ -198,7 +201,7 @@ export function CharacterTabContent({
|
||||
<div className="relative">
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={MOCK_CHARACTERS[selectedCharacterIndex].voiceUrl}
|
||||
src={currentRole.sound}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onEnded={() => setIsPlaying(false)}
|
||||
/>
|
||||
@ -244,9 +247,9 @@ export function CharacterTabContent({
|
||||
<div className="space-y-4">
|
||||
{/* 角色描述 */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-white/70">角色描述</label>
|
||||
<label className="text-sm text-white/70">Character description</label>
|
||||
<textarea
|
||||
value={MOCK_CHARACTERS[selectedCharacterIndex].characterDescription}
|
||||
value={currentRole.roleDescription}
|
||||
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
|
||||
focus:outline-none focus:border-blue-500 resize-none"
|
||||
@ -254,10 +257,10 @@ export function CharacterTabContent({
|
||||
</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
|
||||
src={MOCK_CHARACTERS[selectedCharacterIndex].avatar}
|
||||
alt={MOCK_CHARACTERS[selectedCharacterIndex].name}
|
||||
src={currentRole.url}
|
||||
alt={currentRole.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<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">
|
||||
<GlassIconButton
|
||||
icon={Wand2}
|
||||
tooltip="重新生成角色形象"
|
||||
tooltip="Regenerate character"
|
||||
onClick={() => console.log('regenerate character')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -19,15 +19,17 @@ interface EditModalProps {
|
||||
sketchVideo: any[];
|
||||
currentSketchIndex: number;
|
||||
onSketchSelect: (index: number) => void;
|
||||
roles?: any[];
|
||||
music?: any;
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: '1', label: '脚本', icon: FileText },
|
||||
{ id: '2', label: '角色', icon: Users },
|
||||
{ id: '3', label: '分镜视频', icon: Video },
|
||||
{ id: '4', label: '音乐', icon: Music },
|
||||
{ id: '1', label: 'Script', icon: FileText },
|
||||
{ id: '2', label: 'Character', icon: Users },
|
||||
{ id: '3', label: 'Sketch video', icon: Video },
|
||||
{ id: '4', label: 'Music', icon: Music },
|
||||
// { id: '5', label: '剪辑', icon: Scissors },
|
||||
{ id: 'settings', label: '设置', icon: Settings },
|
||||
{ id: 'settings', label: 'Settings', icon: Settings },
|
||||
];
|
||||
|
||||
export function EditModal({
|
||||
@ -38,15 +40,22 @@ export function EditModal({
|
||||
taskSketch,
|
||||
sketchVideo,
|
||||
currentSketchIndex,
|
||||
onSketchSelect
|
||||
onSketchSelect,
|
||||
roles = [],
|
||||
music
|
||||
}: EditModalProps) {
|
||||
const [activeTab, setActiveTab] = useState(activeEditTab);
|
||||
const [currentIndex, setCurrentIndex] = useState(currentSketchIndex);
|
||||
|
||||
// 当 activeEditTab 改变时更新 activeTab
|
||||
useEffect(() => {
|
||||
setActiveTab(activeEditTab);
|
||||
}, [activeEditTab]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentIndex(currentSketchIndex);
|
||||
}, [isOpen]);
|
||||
|
||||
const isTabDisabled = (tabId: string) => {
|
||||
if (tabId === 'settings') return false;
|
||||
return parseInt(tabId) > parseInt(taskStatus);
|
||||
@ -58,7 +67,7 @@ export function EditModal({
|
||||
return (
|
||||
<ScriptTabContent
|
||||
taskSketch={taskSketch}
|
||||
currentSketchIndex={currentSketchIndex}
|
||||
currentSketchIndex={currentIndex}
|
||||
onSketchSelect={onSketchSelect}
|
||||
/>
|
||||
);
|
||||
@ -66,15 +75,16 @@ export function EditModal({
|
||||
return (
|
||||
<CharacterTabContent
|
||||
taskSketch={taskSketch}
|
||||
currentSketchIndex={currentSketchIndex}
|
||||
currentSketchIndex={currentIndex}
|
||||
onSketchSelect={onSketchSelect}
|
||||
roles={roles}
|
||||
/>
|
||||
);
|
||||
case '3':
|
||||
return (
|
||||
<VideoTabContent
|
||||
taskSketch={sketchVideo}
|
||||
currentSketchIndex={currentSketchIndex}
|
||||
currentSketchIndex={currentIndex}
|
||||
onSketchSelect={onSketchSelect}
|
||||
isPlaying={false}
|
||||
/>
|
||||
@ -83,8 +93,9 @@ export function EditModal({
|
||||
return (
|
||||
<MusicTabContent
|
||||
taskSketch={taskSketch}
|
||||
currentSketchIndex={currentSketchIndex}
|
||||
currentSketchIndex={currentIndex}
|
||||
onSketchSelect={onSketchSelect}
|
||||
music={music}
|
||||
/>
|
||||
);
|
||||
case 'settings':
|
||||
@ -99,7 +110,7 @@ export function EditModal({
|
||||
default:
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@ -200,14 +211,14 @@ export function EditModal({
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={onClose}
|
||||
>
|
||||
重置
|
||||
Reset
|
||||
</motion.button>
|
||||
<motion.button
|
||||
className="px-4 py-2 rounded-lg bg-blue-500 text-white hover:bg-blue-600 transition-colors"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
保存
|
||||
Save
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
90
components/ui/error-boundary.tsx
Normal file
90
components/ui/error-boundary.tsx
Normal 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 };
|
||||
}
|
||||
@ -64,7 +64,7 @@ export function GenerateVideoModal({
|
||||
</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">
|
||||
<label className="text-sm text-white/70">描述场景</label>
|
||||
|
||||
@ -170,7 +170,7 @@ export function MediaPropertiesModal({
|
||||
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">
|
||||
<span className="text-xs text-white/90">场景 {index + 1}</span>
|
||||
<span className="text-xs text-white/90">Scene {index + 1}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
@ -314,10 +314,10 @@ export function MediaPropertiesModal({
|
||||
}}
|
||||
>
|
||||
<option value="Auto">Auto</option>
|
||||
<option value="淡入淡出">淡入淡出</option>
|
||||
<option value="滑动">滑动</option>
|
||||
<option value="缩放">缩放</option>
|
||||
<option value="旋转">旋转</option>
|
||||
<option value="Fade">Fade</option>
|
||||
<option value="Slide">Slide</option>
|
||||
<option value="Zoom">Zoom</option>
|
||||
<option value="Rotate">Rotate</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,44 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useRef } from 'react';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Upload, Library, Play, Pause, RefreshCw, Music2, Volume2 } from 'lucide-react';
|
||||
import { cn } from '@/public/lib/utils';
|
||||
import { GlassIconButton } from './glass-icon-button';
|
||||
import { ReplaceMusicModal } from './replace-music-modal';
|
||||
|
||||
interface Music {
|
||||
url: string;
|
||||
script: string;
|
||||
name?: string;
|
||||
duration?: string;
|
||||
totalDuration?: string;
|
||||
isLooped?: boolean;
|
||||
}
|
||||
|
||||
interface MusicTabContentProps {
|
||||
taskSketch: any[];
|
||||
currentSketchIndex: number;
|
||||
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({
|
||||
taskSketch,
|
||||
currentSketchIndex,
|
||||
onSketchSelect
|
||||
onSketchSelect,
|
||||
music
|
||||
}: MusicTabContentProps) {
|
||||
const [selectedMusicIndex, setSelectedMusicIndex] = useState(0);
|
||||
const [isReplaceModalOpen, setIsReplaceModalOpen] = useState(false);
|
||||
const [activeMethod, setActiveMethod] = useState('upload');
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
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);
|
||||
|
||||
// 处理音频播放进度
|
||||
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 = () => {
|
||||
if (audioRef.current) {
|
||||
const progress = (audioRef.current.currentTime / audioRef.current.duration) * 100;
|
||||
@ -46,7 +69,6 @@ export function MusicTabContent({
|
||||
}
|
||||
};
|
||||
|
||||
// 处理播放/暂停
|
||||
const togglePlay = () => {
|
||||
if (audioRef.current) {
|
||||
if (isPlaying) {
|
||||
@ -58,7 +80,6 @@ export function MusicTabContent({
|
||||
}
|
||||
};
|
||||
|
||||
// 处理进度条点击
|
||||
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (audioRef.current) {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
@ -70,7 +91,6 @@ export function MusicTabContent({
|
||||
}
|
||||
};
|
||||
|
||||
// 处理音量变化
|
||||
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newVolume = parseInt(e.target.value);
|
||||
setVolume(newVolume);
|
||||
@ -81,20 +101,13 @@ export function MusicTabContent({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* 上部分:音乐列表 */}
|
||||
<motion.div
|
||||
className="space-y-4"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
{MOCK_MUSICS.map((music, index) => (
|
||||
<motion.div
|
||||
key={music.id}
|
||||
className={cn(
|
||||
'group relative p-4 rounded-lg overflow-hidden cursor-pointer transition-colors w-[272px]',
|
||||
selectedMusicIndex === index ? 'border border-blue-500 bg-blue-500/10' : ''
|
||||
)}
|
||||
onClick={() => setSelectedMusicIndex(index)}
|
||||
className="group relative p-4 w-[280px] rounded-lg overflow-hidden border border-blue-500 bg-blue-500/10"
|
||||
whileHover={{ scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
>
|
||||
@ -104,22 +117,20 @@ export function MusicTabContent({
|
||||
</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>
|
||||
<h3 className="text-sm font-medium truncate">{music.name || music.script || 'Background music'}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* 中间部分:替换区 */}
|
||||
<motion.div
|
||||
className="p-4 rounded-lg bg-white/5"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
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">
|
||||
<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"
|
||||
@ -128,7 +139,7 @@ export function MusicTabContent({
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<Upload className="w-6 h-6" />
|
||||
<span>上传音乐</span>
|
||||
<span>Upload music</span>
|
||||
</motion.button>
|
||||
|
||||
<motion.button
|
||||
@ -138,21 +149,18 @@ export function MusicTabContent({
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<Library className="w-6 h-6" />
|
||||
<span>音乐库</span>
|
||||
<span>Music library</span>
|
||||
</motion.button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 下部分:音乐属性 */}
|
||||
<motion.div
|
||||
className="grid grid-cols-2 gap-6"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
{/* 左列:音乐编辑选项 */}
|
||||
<div className="space-y-6">
|
||||
{/* Loop Music */}
|
||||
<motion.div
|
||||
className="space-y-2"
|
||||
whileHover={{ scale: 1.01 }}
|
||||
@ -162,16 +170,16 @@ export function MusicTabContent({
|
||||
<motion.div
|
||||
className={cn(
|
||||
"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
|
||||
>
|
||||
<motion.div
|
||||
className="w-4 h-4 bg-white rounded-full"
|
||||
layout
|
||||
animate={{
|
||||
x: MOCK_MUSICS[selectedMusicIndex].isLooped ? "100%" : "0%"
|
||||
x: isLooped ? "100%" : "0%"
|
||||
}}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 25 }}
|
||||
/>
|
||||
@ -179,7 +187,6 @@ export function MusicTabContent({
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Trim */}
|
||||
<motion.div
|
||||
className="space-y-2"
|
||||
whileHover={{ scale: 1.01 }}
|
||||
@ -190,7 +197,8 @@ export function MusicTabContent({
|
||||
<span className="text-sm text-white/50">from</span>
|
||||
<input
|
||||
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
|
||||
focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
@ -199,7 +207,8 @@ export function MusicTabContent({
|
||||
<span className="text-sm text-white/50">to</span>
|
||||
<input
|
||||
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
|
||||
focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
@ -207,7 +216,6 @@ export function MusicTabContent({
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Fade in & out */}
|
||||
<motion.div
|
||||
className="space-y-4"
|
||||
whileHover={{ scale: 1.01 }}
|
||||
@ -218,7 +226,8 @@ export function MusicTabContent({
|
||||
<span className="text-xs text-white/50">Fade in:</span>
|
||||
<input
|
||||
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
|
||||
focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
@ -227,7 +236,8 @@ export function MusicTabContent({
|
||||
<span className="text-xs text-white/50">Fade out:</span>
|
||||
<input
|
||||
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
|
||||
focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
@ -235,7 +245,6 @@ export function MusicTabContent({
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Music volume */}
|
||||
<motion.div
|
||||
className="space-y-2"
|
||||
whileHover={{ scale: 1.01 }}
|
||||
@ -263,7 +272,6 @@ export function MusicTabContent({
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* 右列:音频预览 */}
|
||||
<motion.div
|
||||
className="p-6 rounded-lg bg-white/5 flex flex-col items-center justify-center gap-6"
|
||||
whileHover={{ scale: 1.01 }}
|
||||
@ -307,10 +315,18 @@ export function MusicTabContent({
|
||||
) : '0:00 / 0:00'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={music.url}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onEnded={() => setIsPlaying(false)}
|
||||
loop={isLooped}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* 替换音乐弹窗 */}
|
||||
<ReplaceMusicModal
|
||||
isOpen={isReplaceModalOpen}
|
||||
activeReplaceMethod={activeMethod}
|
||||
|
||||
@ -37,12 +37,6 @@ export function ReplaceCharacterModal({
|
||||
id: 2,
|
||||
avatar: '/assets/3dr_mono.png',
|
||||
name: '春 (HARU)',
|
||||
style: '写实风格'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
avatar: '/assets/3dr_chihiro.png',
|
||||
name: '夏 (NATSU)',
|
||||
style: '二次元风格'
|
||||
},
|
||||
];
|
||||
@ -90,7 +84,7 @@ export function ReplaceCharacterModal({
|
||||
>
|
||||
<ChevronDown className="w-5 h-5" />
|
||||
</button>
|
||||
<h2 className="text-lg font-medium">替换角色</h2>
|
||||
<h2 className="text-lg font-medium">Replace character</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -110,7 +104,7 @@ export function ReplaceCharacterModal({
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<Upload className="w-6 h-6" />
|
||||
<span>上传角色</span>
|
||||
<span>Upload character</span>
|
||||
</motion.button>
|
||||
|
||||
<motion.button
|
||||
@ -125,7 +119,7 @@ export function ReplaceCharacterModal({
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<Library className="w-6 h-6" />
|
||||
<span>角色库</span>
|
||||
<span>Library</span>
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
@ -158,8 +152,8 @@ export function ReplaceCharacterModal({
|
||||
<Image className="w-8 h-8 text-white/70" />
|
||||
</motion.div>
|
||||
<div className="text-center">
|
||||
<p className="text-white/70">点击上传或拖拽角色图片到此处</p>
|
||||
<p className="text-sm text-white/50 mt-2">支持 PNG, JPG, WEBP 格式</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">Supports PNG, JPG, WEBP formats</p>
|
||||
</div>
|
||||
</label>
|
||||
</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" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索角色..."
|
||||
placeholder="Search character..."
|
||||
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"
|
||||
value={searchQuery}
|
||||
@ -230,7 +224,7 @@ export function ReplaceCharacterModal({
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
<span>生成新角色</span>
|
||||
<span>Generate new character</span>
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
)}
|
||||
@ -246,14 +240,14 @@ export function ReplaceCharacterModal({
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={onClose}
|
||||
>
|
||||
重置
|
||||
Reset
|
||||
</motion.button>
|
||||
<motion.button
|
||||
className="px-4 py-2 rounded-lg bg-blue-500 text-white hover:bg-blue-600 transition-colors"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
应用
|
||||
Apply
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -93,7 +93,7 @@ export function ReplaceMusicModal({
|
||||
>
|
||||
<ChevronDown className="w-5 h-5" />
|
||||
</button>
|
||||
<h2 className="text-lg font-medium">替换音乐</h2>
|
||||
<h2 className="text-lg font-medium">Replace music</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -113,7 +113,7 @@ export function ReplaceMusicModal({
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<Upload className="w-6 h-6" />
|
||||
<span>上传音乐</span>
|
||||
<span>Upload music</span>
|
||||
</motion.button>
|
||||
|
||||
<motion.button
|
||||
@ -128,7 +128,7 @@ export function ReplaceMusicModal({
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<Library className="w-6 h-6" />
|
||||
<span>音乐库</span>
|
||||
<span>Library</span>
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
@ -161,8 +161,8 @@ export function ReplaceMusicModal({
|
||||
<Music2 className="w-8 h-8 text-white/70" />
|
||||
</motion.div>
|
||||
<div className="text-center">
|
||||
<p className="text-white/70">点击上传或拖拽音乐文件到此处</p>
|
||||
<p className="text-sm text-white/50 mt-2">支持 MP3, WAV, M4A 格式</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">Supports MP3, WAV, M4A formats</p>
|
||||
</div>
|
||||
</label>
|
||||
</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" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索音乐..."
|
||||
placeholder="Search music..."
|
||||
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"
|
||||
value={searchQuery}
|
||||
@ -229,14 +229,14 @@ export function ReplaceMusicModal({
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={onClose}
|
||||
>
|
||||
重置
|
||||
Reset
|
||||
</motion.button>
|
||||
<motion.button
|
||||
className="px-4 py-2 rounded-lg bg-blue-500 text-white hover:bg-blue-600 transition-colors"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
应用
|
||||
Apply
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -77,7 +77,7 @@ export function ReplaceVideoModal({
|
||||
>
|
||||
<ChevronDown className="w-5 h-5" />
|
||||
</button>
|
||||
<h2 className="text-lg font-medium">视频替换替换</h2>
|
||||
<h2 className="text-lg font-medium">Video replacement</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -97,7 +97,7 @@ export function ReplaceVideoModal({
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<Upload className="w-6 h-6" />
|
||||
<span>上传视频</span>
|
||||
<span>Upload video</span>
|
||||
</motion.button>
|
||||
|
||||
<motion.button
|
||||
@ -112,7 +112,7 @@ export function ReplaceVideoModal({
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<Library className="w-6 h-6" />
|
||||
<span>素材库</span>
|
||||
<span>Library</span>
|
||||
</motion.button>
|
||||
|
||||
<motion.button
|
||||
@ -127,7 +127,7 @@ export function ReplaceVideoModal({
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<Wand2 className="w-6 h-6" />
|
||||
<span>生成视频</span>
|
||||
<span>Generate video</span>
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
@ -160,8 +160,8 @@ export function ReplaceVideoModal({
|
||||
<FileVideo className="w-8 h-8 text-white/70" />
|
||||
</motion.div>
|
||||
<div className="text-center">
|
||||
<p className="text-white/70">点击上传或拖拽视频文件到此处</p>
|
||||
<p className="text-sm text-white/50 mt-2">支持 MP4, MOV, AVI 格式</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">Supports MP4, MOV, AVI formats</p>
|
||||
</div>
|
||||
</label>
|
||||
</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" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索视频..."
|
||||
placeholder="Search video..."
|
||||
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"
|
||||
value={searchQuery}
|
||||
@ -236,7 +236,7 @@ export function ReplaceVideoModal({
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
<span>生成新视频</span>
|
||||
<span>Generate new video</span>
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
)}
|
||||
@ -252,14 +252,14 @@ export function ReplaceVideoModal({
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={onClose}
|
||||
>
|
||||
重置
|
||||
Reset
|
||||
</motion.button>
|
||||
<motion.button
|
||||
className="px-4 py-2 rounded-lg bg-blue-500 text-white hover:bg-blue-600 transition-colors"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
应用
|
||||
Apply
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Trash2, RefreshCw } from 'lucide-react';
|
||||
import { GlassIconButton } from './glass-icon-button';
|
||||
import { cn } from '@/public/lib/utils';
|
||||
|
||||
interface ScriptTabContentProps {
|
||||
@ -23,12 +22,6 @@ export function ScriptTabContent({
|
||||
// 确保 taskSketch 是数组
|
||||
const sketches = Array.isArray(taskSketch) ? taskSketch : [];
|
||||
|
||||
// 模拟脚本数据
|
||||
const mockScripts = sketches.map((_, index) => ({
|
||||
id: `script-${index}`,
|
||||
content: `这是第 ${index + 1} 个分镜的脚本内容,描述了场景中的主要动作和对话。这里可以添加更多细节来丰富场景表现。`
|
||||
}));
|
||||
|
||||
// 自动滚动到选中项
|
||||
useEffect(() => {
|
||||
if (thumbnailsRef.current && scriptsRef.current) {
|
||||
@ -61,7 +54,7 @@ export function ScriptTabContent({
|
||||
if (sketches.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] text-white/50">
|
||||
<p>暂无分镜数据</p>
|
||||
<p>No sketch data</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -93,11 +86,11 @@ export function ScriptTabContent({
|
||||
>
|
||||
<img
|
||||
src={sketch.url}
|
||||
alt={`分镜 ${index + 1}`}
|
||||
alt={`Sketch ${index + 1}`}
|
||||
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">
|
||||
<span className="text-xs text-white/90">场景 {index + 1}</span>
|
||||
<span className="text-xs text-white/90">Scene {index + 1}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
@ -110,7 +103,7 @@ export function ScriptTabContent({
|
||||
ref={scriptsRef}
|
||||
className="flex overflow-x-auto hide-scrollbar py-2 gap-1"
|
||||
>
|
||||
{mockScripts.map((script, index) => {
|
||||
{sketches.map((script, index) => {
|
||||
const isActive = currentSketchIndex === index;
|
||||
return (
|
||||
<motion.div
|
||||
@ -127,9 +120,9 @@ export function ScriptTabContent({
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm whitespace-nowrap">
|
||||
{script.content}
|
||||
{script.script}
|
||||
</span>
|
||||
{index < mockScripts.length - 1 && (
|
||||
{index < sketches.length - 1 && (
|
||||
<span className="text-white/20">|</span>
|
||||
)}
|
||||
</div>
|
||||
@ -156,7 +149,7 @@ export function ScriptTabContent({
|
||||
<motion.textarea
|
||||
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"
|
||||
value={mockScripts[currentSketchIndex]?.content}
|
||||
value={sketches[currentSketchIndex]?.script}
|
||||
onChange={() => {}}
|
||||
layoutId="script-editor"
|
||||
/>
|
||||
@ -171,7 +164,7 @@ export function ScriptTabContent({
|
||||
>
|
||||
<img
|
||||
src={sketches[currentSketchIndex]?.url}
|
||||
alt={`分镜 ${currentSketchIndex + 1}`}
|
||||
alt={`Sketch ${currentSketchIndex + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</motion.div>
|
||||
@ -179,20 +172,20 @@ export function ScriptTabContent({
|
||||
{/* 操作按钮 */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<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
|
||||
text-red-500 rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<span>删除分镜</span>
|
||||
<span>Delete sketch</span>
|
||||
</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
|
||||
text-blue-500 rounded-lg transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
<span>重新生成</span>
|
||||
<span>Regenerate</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -180,19 +180,19 @@ export function SettingsTabContent({ onSettingChange }: SettingsTabContentProps)
|
||||
<div className="space-y-4">
|
||||
<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" />
|
||||
<h3 className="text-sm font-medium text-white/90">基础设置</h3>
|
||||
<h3 className="text-sm font-medium text-white/90">Basic settings</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* 工作模式 */}
|
||||
<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)}
|
||||
</div>
|
||||
|
||||
{/* 分辨率 */}
|
||||
<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)}
|
||||
</div>
|
||||
</div>
|
||||
@ -202,19 +202,19 @@ export function SettingsTabContent({ onSettingChange }: SettingsTabContentProps)
|
||||
<div className="space-y-4">
|
||||
<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" />
|
||||
<h3 className="text-sm font-medium text-white/90">视觉效果</h3>
|
||||
<h3 className="text-sm font-medium text-white/90">Visual effects</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* 叠加效果 */}
|
||||
<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)}
|
||||
</div>
|
||||
|
||||
{/* 转场设定 */}
|
||||
<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)}
|
||||
</div>
|
||||
</div>
|
||||
@ -224,25 +224,25 @@ export function SettingsTabContent({ onSettingChange }: SettingsTabContentProps)
|
||||
<div className="space-y-4">
|
||||
<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" />
|
||||
<h3 className="text-sm font-medium text-white/90">文字样式</h3>
|
||||
<h3 className="text-sm font-medium text-white/90">Text style</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* 字幕风格 */}
|
||||
<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)}
|
||||
</div>
|
||||
|
||||
{/* 文字效果 */}
|
||||
<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)}
|
||||
</div>
|
||||
|
||||
{/* 贴纸预设 */}
|
||||
<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)}
|
||||
</div>
|
||||
</div>
|
||||
@ -252,7 +252,7 @@ export function SettingsTabContent({ onSettingChange }: SettingsTabContentProps)
|
||||
<div className="space-y-4">
|
||||
<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" />
|
||||
<h3 className="text-sm font-medium text-white/90">音频设置</h3>
|
||||
<h3 className="text-sm font-medium text-white/90">Audio settings</h3>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{renderVolumeSlider(
|
||||
<Volume2 className="w-4 h-4 text-amber-500" />,
|
||||
"音效主音量",
|
||||
"SFX volume",
|
||||
sfxVolume,
|
||||
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">
|
||||
{renderVolumeSlider(
|
||||
<Mic className="w-4 h-4 text-amber-500" />,
|
||||
"配音主音量",
|
||||
"Voice volume",
|
||||
voiceVolume,
|
||||
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">
|
||||
{renderVolumeSlider(
|
||||
<Radio className="w-4 h-4 text-amber-500" />,
|
||||
"媒体音频主音量",
|
||||
"Media volume",
|
||||
mediaVolume,
|
||||
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">
|
||||
{renderVolumeSlider(
|
||||
<Music className="w-4 h-4 text-amber-500" />,
|
||||
"音乐主音量",
|
||||
"Music volume",
|
||||
musicVolume,
|
||||
setMusicVolume
|
||||
)}
|
||||
@ -306,9 +306,9 @@ export function SettingsTabContent({ onSettingChange }: SettingsTabContentProps)
|
||||
<Volume2 className="w-4 h-4 text-amber-500" />
|
||||
</div>
|
||||
<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">
|
||||
建议保持音效、配音、媒体音频和音乐之间的适当平衡,以获得最佳听觉体验
|
||||
It is recommended to maintain a balance between SFX, voice, media audio, and music to achieve the best auditory experience
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -39,14 +39,6 @@ export function VideoTabContent({
|
||||
// 确保 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(() => {
|
||||
if (thumbnailsRef.current && videosRef.current) {
|
||||
@ -98,7 +90,7 @@ export function VideoTabContent({
|
||||
if (sketches.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] text-white/50">
|
||||
<p>暂无分镜数据</p>
|
||||
<p>No sketch data</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -138,7 +130,7 @@ export function VideoTabContent({
|
||||
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">
|
||||
<span className="text-xs text-white/90">场景 {index + 1}</span>
|
||||
<span className="text-xs text-white/90">Scene {index + 1}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
@ -151,11 +143,11 @@ export function VideoTabContent({
|
||||
ref={videosRef}
|
||||
className="flex overflow-x-auto hide-scrollbar py-2 gap-1"
|
||||
>
|
||||
{mockVideos.map((video, index) => {
|
||||
{sketches.map((video, index) => {
|
||||
const isActive = currentSketchIndex === index;
|
||||
return (
|
||||
<motion.div
|
||||
key={video.id}
|
||||
key={video.id || index}
|
||||
className={cn(
|
||||
'flex-shrink-0 cursor-pointer transition-all duration-300',
|
||||
isActive ? 'text-white' : 'text-white/50 hover:text-white/80'
|
||||
@ -168,9 +160,9 @@ export function VideoTabContent({
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm whitespace-nowrap">
|
||||
{video.description}
|
||||
{video.script}
|
||||
</span>
|
||||
{index < mockVideos.length - 1 && (
|
||||
{index < sketches.length - 1 && (
|
||||
<span className="text-white/20">|</span>
|
||||
)}
|
||||
</div>
|
||||
@ -192,7 +184,7 @@ export function VideoTabContent({
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
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">
|
||||
<motion.button
|
||||
className={cn(
|
||||
@ -209,7 +201,7 @@ export function VideoTabContent({
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<Upload className="w-6 h-6" />
|
||||
<span>上传视频</span>
|
||||
<span>Upload video</span>
|
||||
</motion.button>
|
||||
|
||||
<motion.button
|
||||
@ -227,7 +219,7 @@ export function VideoTabContent({
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<Library className="w-6 h-6" />
|
||||
<span>素材库</span>
|
||||
<span>Library</span>
|
||||
</motion.button>
|
||||
|
||||
<motion.button
|
||||
@ -245,7 +237,7 @@ export function VideoTabContent({
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<Wand2 className="w-6 h-6" />
|
||||
<span>生成视频</span>
|
||||
<span>Generate video</span>
|
||||
</motion.button>
|
||||
</div>
|
||||
</motion.div>
|
||||
@ -261,7 +253,7 @@ export function VideoTabContent({
|
||||
<div className="space-y-4">
|
||||
{/* 视频截取 */}
|
||||
<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">
|
||||
<input
|
||||
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
|
||||
text-center focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
<span className="text-white/50">至</span>
|
||||
<span className="text-white/50">To</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="00:00"
|
||||
@ -281,9 +273,9 @@ export function VideoTabContent({
|
||||
|
||||
{/* 转场设置 */}
|
||||
<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">
|
||||
{['淡入淡出', '滑动', '缩放'].map((transition) => (
|
||||
{['Fade', 'Slide', 'Zoom'].map((transition) => (
|
||||
<motion.button
|
||||
key={transition}
|
||||
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">
|
||||
<h3 className="text-sm font-medium mb-2">音量调节</h3>
|
||||
<h3 className="text-sm font-medium mb-2">Volume</h3>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
@ -385,8 +377,6 @@ export function VideoTabContent({
|
||||
<Volume2 className="w-5 h-5" />
|
||||
)}
|
||||
</motion.button>
|
||||
|
||||
<span className="text-sm">{mockVideos[currentSketchIndex]?.duration}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -395,24 +385,24 @@ export function VideoTabContent({
|
||||
{/* 操作按钮 */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<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
|
||||
text-red-500 rounded-lg transition-colors"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<span>删除分镜</span>
|
||||
<span>Delete sketch</span>
|
||||
</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
|
||||
text-blue-500 rounded-lg transition-colors"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
<span>重新生成</span>
|
||||
<span>Regenerate</span>
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -2,27 +2,27 @@ import {
|
||||
TaskObject,
|
||||
SketchItem,
|
||||
VideoItem,
|
||||
MOCK_SKETCH_URLS,
|
||||
MOCK_SKETCH_SCRIPT,
|
||||
MOCK_VIDEO_URLS,
|
||||
MOCK_SKETCH_COUNT
|
||||
getRandomMockData
|
||||
} from './constants';
|
||||
|
||||
// 当前选择的mock数据
|
||||
let selectedMockData = getRandomMockData();
|
||||
|
||||
// 模拟接口请求 获取任务详情
|
||||
export const getTaskDetail = async (taskId: string): Promise<TaskObject> => {
|
||||
// const response = await fetch(`/api/task/${taskId}`);
|
||||
// const data = await response.json();
|
||||
// mock data
|
||||
// 每次获取任务详情时重新随机选择数据
|
||||
selectedMockData = getRandomMockData();
|
||||
|
||||
const data: TaskObject = {
|
||||
projectId: 'projectId-123',
|
||||
projectName: "Project 1",
|
||||
projectId: selectedMockData.detail.projectId,
|
||||
projectName: selectedMockData.detail.projectName,
|
||||
taskId: taskId,
|
||||
taskName: "Task 1",
|
||||
taskDescription: "Task 1 Description",
|
||||
taskStatus: "1", // '1' 绘制分镜、'2' 绘制角色、'3' 生成分镜视频、'4' 视频后期制作、'5' 最终成品
|
||||
taskName: selectedMockData.detail.taskName,
|
||||
taskDescription: selectedMockData.detail.taskDescription,
|
||||
taskStatus: selectedMockData.detail.taskStatus,
|
||||
taskProgress: 0,
|
||||
mode: 'auto', // 全自动模式、人工干预模式
|
||||
resolution: '1080p', // 1080p、2160p
|
||||
mode: selectedMockData.detail.mode,
|
||||
resolution: selectedMockData.detail.resolution.toString(),
|
||||
};
|
||||
return data;
|
||||
};
|
||||
@ -32,14 +32,17 @@ export const getTaskSketch = async (
|
||||
taskId: string,
|
||||
onProgress: (sketch: SketchItem, index: number) => 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秒延迟
|
||||
|
||||
const newSketch: SketchItem = {
|
||||
id: `sketch-${i}`,
|
||||
url: MOCK_SKETCH_URLS[i % MOCK_SKETCH_URLS.length],
|
||||
script: MOCK_SKETCH_SCRIPT[i % MOCK_SKETCH_SCRIPT.length],
|
||||
url: sketchData[i].url,
|
||||
script: sketchData[i].script,
|
||||
status: 'done'
|
||||
};
|
||||
|
||||
@ -68,14 +71,17 @@ export const getTaskVideo = async (
|
||||
sketchCount: number,
|
||||
onProgress: (video: VideoItem, index: number) => 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秒延迟
|
||||
|
||||
const newVideo: VideoItem = {
|
||||
id: `video-${i}`,
|
||||
url: MOCK_VIDEO_URLS[i % MOCK_VIDEO_URLS.length],
|
||||
script: MOCK_SKETCH_SCRIPT[i % MOCK_SKETCH_SCRIPT.length],
|
||||
url: videoData[i].url,
|
||||
script: videoData[i].script,
|
||||
status: 'done'
|
||||
};
|
||||
|
||||
|
||||
@ -1,30 +1,222 @@
|
||||
export const MOCK_SKETCH_URLS = [
|
||||
'https://d3phaj0sisr2ct.cloudfront.net/app/gen4/object-reference/welcome-ref-1.jpg',
|
||||
'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',
|
||||
// 5组mock数据
|
||||
export const MOCK_DATA = [
|
||||
{
|
||||
id: 1,
|
||||
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 = [
|
||||
'script-123',
|
||||
'script-123',
|
||||
'script-123',
|
||||
'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',
|
||||
// 随机选择一组mock数据
|
||||
export const getRandomMockData = () => {
|
||||
const randomIndex = Math.floor(Math.random() * MOCK_DATA.length);
|
||||
return MOCK_DATA[randomIndex];
|
||||
};
|
||||
|
||||
export interface TaskObject {
|
||||
@ -43,6 +235,7 @@ export interface SketchItem {
|
||||
id: string;
|
||||
url: string;
|
||||
script: string;
|
||||
bg_rgb?: string[];
|
||||
status: string;
|
||||
}
|
||||
|
||||
@ -53,14 +246,19 @@ export interface VideoItem {
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface FinalVideo {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export const STEP_MESSAGES = {
|
||||
loading: '正在加载任务信息...',
|
||||
sketch: (count: number, total: number) => `正在生成分镜草图 ${count + 1}/${total}...`,
|
||||
sketchComplete: '分镜草图生成完成',
|
||||
character: '正在绘制角色...',
|
||||
video: (count: number, total: number) => `正在生成分镜视频 ${count + 1}/${total}...`,
|
||||
videoComplete: '分镜视频生成完成',
|
||||
audio: '正在生成背景音...',
|
||||
final: '正在生成最终成品...',
|
||||
complete: '任务完成'
|
||||
loading: 'Loading task information...',
|
||||
sketch: (count: number, total: number) => `Generating sketch ${count + 1 > total ? total : count + 1}/${total}...`,
|
||||
sketchComplete: 'Sketch generation complete',
|
||||
character: 'Drawing characters...',
|
||||
video: (count: number, total: number) => `Generating video ${count + 1 > total ? total : count + 1}/${total}...`,
|
||||
videoComplete: 'Video generation complete',
|
||||
audio: 'Generating background audio...',
|
||||
postProduction: (step: string) => `Post-production: ${step}...`,
|
||||
final: 'Generating final product...',
|
||||
complete: 'Task completed'
|
||||
};
|
||||
@ -1,5 +1,5 @@
|
||||
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 {
|
||||
getTaskDetail,
|
||||
getTaskSketch,
|
||||
@ -17,7 +17,8 @@ export const useWorkFlow = () => {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [currentStep, setCurrentStep] = 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);
|
||||
@ -107,13 +108,18 @@ export const useWorkFlow = () => {
|
||||
if (prev.find(sketch => sketch.id === newSketch.id)) {
|
||||
return prev;
|
||||
}
|
||||
return [...prev, newSketch];
|
||||
const newSketchList = [...prev, newSketch];
|
||||
if (index === 0) {
|
||||
// 这里我们需要从API获取总数,暂时使用当前逻辑
|
||||
}
|
||||
return newSketchList;
|
||||
});
|
||||
setCurrentSketchIndex(index);
|
||||
setSketchCount(index + 1);
|
||||
});
|
||||
|
||||
setIsGeneratingSketch(false);
|
||||
setTotalSketchCount(taskSketch.length);
|
||||
};
|
||||
|
||||
const handleGetTaskVideo = async (taskId: string) => {
|
||||
@ -199,7 +205,7 @@ export const useWorkFlow = () => {
|
||||
case '1':
|
||||
setCurrentLoadingText(
|
||||
isGeneratingSketch
|
||||
? STEP_MESSAGES.sketch(sketchCount, MOCK_SKETCH_COUNT)
|
||||
? STEP_MESSAGES.sketch(sketchCount, Math.max(totalSketchCount, sketchCount + 1))
|
||||
: STEP_MESSAGES.sketchComplete
|
||||
);
|
||||
break;
|
||||
@ -222,7 +228,7 @@ export const useWorkFlow = () => {
|
||||
default:
|
||||
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(() => {
|
||||
|
||||
@ -3,7 +3,34 @@ const nextConfig = {
|
||||
eslint: {
|
||||
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() {
|
||||
return [
|
||||
{
|
||||
|
||||
1045
package-lock.json
generated
1045
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -52,6 +52,7 @@
|
||||
"antd": "^5.26.2",
|
||||
"autoprefixer": "10.4.15",
|
||||
"axios": "^1.10.0",
|
||||
"babel-loader": "^10.0.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
|
||||
152
utils/dev-helper.ts
Normal file
152
utils/dev-helper.ts
Normal 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';
|
||||
Loading…
x
Reference in New Issue
Block a user