完善分镜视频对应的音频设置

This commit is contained in:
北枳 2025-07-02 17:18:21 +08:00
parent a5a6da472d
commit 34348881b3
17 changed files with 989 additions and 1411 deletions

View File

@ -1 +1 @@
export const BASE_URL = "https://movieflow.api.huiying.video"
export const BASE_URL = "/api/proxy"

View File

@ -18,7 +18,7 @@ export interface CreateScriptProjectRequest {
resolution?: number;
}
// 创建剧本项目响应数据类型
// 剧本项目数据类型
export interface ScriptProject {
id: number;
title: string;
@ -32,11 +32,60 @@ export interface ScriptProject {
status: number;
cate_tags: string[];
creator_name: string;
updated_at?: string;
created_at?: string;
mode: number;
resolution: number;
}
// 获取剧本项目列表请求数据类型
export interface GetScriptProjectListRequest {
page: number; // 页码 从1开始
per_page: number; // 每页条数 默认10条
sort_by: string; // 排序字段 默认update_time
sort_order: string; // 排序顺序 默认desc
project_type: number; // 项目类型 默认1
}
// 获取剧本项目列表响应数据类型
export interface ScriptProjectList {
total: number;
items: ScriptProject[];
}
// 修改剧本项目请求数据类型
export interface UpdateScriptProjectRequest {
id: number;
title?: string;
script_author?: string;
characters?: Array<{
name?: string;
desc?: string;
}>;
summary?: string;
status?: number;
}
// 删除剧本项目请求数据类型
export interface DeleteScriptProjectRequest {
id: number;
}
// 创建剧本项目
export const createScriptProject = async (data: CreateScriptProjectRequest): Promise<ApiResponse<ScriptProject>> => {
return post<ApiResponse<ScriptProject>>('/script_project/create', data);
};
// 获取剧本项目列表
export const getScriptProjectList = async (data: GetScriptProjectListRequest): Promise<ApiResponse<ScriptProjectList>> => {
return post<ApiResponse<ScriptProjectList>>('/script_project/page', data);
};
// 修改剧本项目
export const updateScriptProject = async (data: UpdateScriptProjectRequest): Promise<ApiResponse<ScriptProject>> => {
return post<ApiResponse<ScriptProject>>('/script_project/update', data);
};
// 删除剧本项目
export const deleteScriptProject = async (data: DeleteScriptProjectRequest): Promise<ApiResponse<ScriptProject>> => {
return post<ApiResponse<ScriptProject>>('/script_project/delete', data);
};

View File

@ -567,7 +567,7 @@ const ImageQueue = ({ shouldStart, onComplete }: { shouldStart: boolean; onCompl
});
tl.to(images, {
x: -100,
x: 0,
opacity: 1,
rotation: 0,
duration: 1,
@ -797,7 +797,7 @@ const ImageQueue = ({ shouldStart, onComplete }: { shouldStart: boolean; onCompl
onComplete={handleStageTextComplete}
/>
)}
<div ref={imagesRef} className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex gap-4">
<div ref={imagesRef} className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex items-center justify-center gap-4">
{imageUrls.map((url, index) => (
<div
key={index}

View File

@ -116,7 +116,7 @@ export function CreateToVideo2() {
const handleCreateVideo = async () => {
// 创建剧集数据
const episodeData: CreateScriptEpisodeRequest = {
title: "episode 1",
title: "episode default",
script_id: projectId,
status: 1,
summary: script

View File

@ -1,81 +0,0 @@
"use client";
import { useState } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { Separator } from '@/components/ui/separator';
import {
ArrowLeft,
ArrowRight,
FileText,
Users,
Film,
Music,
Video,
CheckCircle,
} from 'lucide-react';
import { InputScriptStep } from '@/components/workflow/input-script-step';
import { GenerateChaptersStep } from '@/components/workflow/generate-chapters-step';
import { GenerateShotsStep } from '@/components/workflow/generate-shots-step';
import { AddMusicStep } from '@/components/workflow/add-music-step';
import { FinalCompositionStep } from '@/components/workflow/final-composition-step';
const steps = [
{ id: 1, name: 'Input Script', icon: FileText, description: 'Enter your script and settings' },
{ id: 2, name: 'Generate Chapters', icon: Users, description: 'AI splits script and assigns actors' },
{ id: 3, name: 'Generate Shots', icon: Film, description: 'Create storyboard and scenes' },
{ id: 4, name: 'Add Music', icon: Music, description: 'Background music and audio' },
{ id: 5, name: 'Final Video', icon: Video, description: 'Compose and export video' },
];
export function CreateVideoWorkflow() {
const [currentStep, setCurrentStep] = useState(1);
const [completedSteps, setCompletedSteps] = useState<number[]>([]);
const handleNext = () => {
if (currentStep < steps.length) {
setCompletedSteps([...completedSteps, currentStep]);
setCurrentStep(currentStep + 1);
}
};
const handlePrevious = () => {
if (currentStep > 1) {
setCurrentStep(currentStep - 1);
}
};
const handleStepClick = (stepId: number) => {
if (stepId <= currentStep || completedSteps.includes(stepId)) {
setCurrentStep(stepId);
}
};
const renderStepContent = () => {
switch (currentStep) {
case 1:
return <InputScriptStep onNext={handleNext} />;
case 2:
return <GenerateChaptersStep onNext={handleNext} onPrevious={handlePrevious} />;
case 3:
return <GenerateShotsStep onNext={handleNext} onPrevious={handlePrevious} />;
case 4:
return <AddMusicStep onNext={handleNext} onPrevious={handlePrevious} />;
case 5:
return <FinalCompositionStep onPrevious={handlePrevious} />;
default:
return null;
}
};
return (
<div className="max-w-6xl mx-auto space-y-8">
{/* Step Content */}
<div>
{renderStepContent()}
</div>
</div>
);
}

View File

@ -1,331 +0,0 @@
"use client";
import { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Search,
Filter,
MoreHorizontal,
Play,
Download,
Trash2,
RefreshCw,
Clock,
CheckCircle,
XCircle,
Eye,
} from 'lucide-react';
const mockTasks = [
{
id: 1,
title: 'Tech Product Demo',
status: 'completed',
progress: 100,
duration: '2:45',
createdAt: '2024-01-15T10:30:00Z',
completedAt: '2024-01-15T11:15:00Z',
chapters: 4,
actors: ['Sarah Chen', 'Dr. Marcus Webb'],
thumbnail: 'https://images.pexels.com/photos/3861969/pexels-photo-3861969.jpeg?auto=compress&cs=tinysrgb&w=300',
},
{
id: 2,
title: 'Marketing Campaign Video',
status: 'processing',
progress: 65,
duration: '1:30',
createdAt: '2024-01-14T15:20:00Z',
completedAt: null,
chapters: 3,
actors: ['Alex Rivera'],
thumbnail: 'https://images.pexels.com/photos/3184287/pexels-photo-3184287.jpeg?auto=compress&cs=tinysrgb&w=300',
},
{
id: 3,
title: 'Educational Content Series',
status: 'completed',
progress: 100,
duration: '5:20',
createdAt: '2024-01-12T09:00:00Z',
completedAt: '2024-01-12T10:30:00Z',
chapters: 6,
actors: ['Dr. Marcus Webb', 'Sarah Chen'],
thumbnail: 'https://images.pexels.com/photos/3184465/pexels-photo-3184465.jpeg?auto=compress&cs=tinysrgb&w=300',
},
{
id: 4,
title: 'Company Introduction',
status: 'failed',
progress: 0,
duration: '0:00',
createdAt: '2024-01-10T14:45:00Z',
completedAt: null,
chapters: 2,
actors: ['Sarah Chen'],
thumbnail: null,
},
{
id: 5,
title: 'Quarterly Report Presentation',
status: 'processing',
progress: 25,
duration: '3:15',
createdAt: '2024-01-09T11:00:00Z',
completedAt: null,
chapters: 5,
actors: ['Dr. Marcus Webb', 'Alex Rivera'],
thumbnail: 'https://images.pexels.com/photos/3184291/pexels-photo-3184291.jpeg?auto=compress&cs=tinysrgb&w=300',
},
];
const statusConfig = {
completed: {
icon: CheckCircle,
color: 'text-green-600',
bgColor: 'bg-green-100 dark:bg-green-900/20',
label: 'Completed',
},
processing: {
icon: RefreshCw,
color: 'text-blue-600',
bgColor: 'bg-blue-100 dark:bg-blue-900/20',
label: 'Processing',
},
failed: {
icon: XCircle,
color: 'text-red-600',
bgColor: 'bg-red-100 dark:bg-red-900/20',
label: 'Failed',
},
};
export function HistoryPage() {
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all');
const filteredTasks = mockTasks.filter(task => {
const matchesSearch = task.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
task.actors.some(actor => actor.toLowerCase().includes(searchQuery.toLowerCase()));
const matchesStatus = statusFilter === 'all' || task.status === statusFilter;
return matchesSearch && matchesStatus;
});
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString();
};
const getTimeSince = (dateString: string) => {
const now = new Date();
const date = new Date(dateString);
const diffInHours = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60));
if (diffInHours < 1) return 'Less than an hour ago';
if (diffInHours < 24) return `${diffInHours} hours ago`;
const diffInDays = Math.floor(diffInHours / 24);
return `${diffInDays} days ago`;
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Task History</h1>
<p className="text-muted-foreground">
Track the progress and manage your video generation tasks
</p>
</div>
</div>
{/* Filters */}
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
<Input
placeholder="Search tasks..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 w-64"
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">
<Filter className="mr-2 h-4 w-4" />
Status: {statusFilter === 'all' ? 'All' : statusConfig[statusFilter as keyof typeof statusConfig]?.label}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => setStatusFilter('all')}>
All Status
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setStatusFilter('completed')}>
Completed
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setStatusFilter('processing')}>
Processing
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setStatusFilter('failed')}>
Failed
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<Badge variant="secondary">
{filteredTasks.length} tasks
</Badge>
</div>
</CardContent>
</Card>
{/* Tasks List */}
<div className="space-y-4">
{filteredTasks.map((task) => {
const StatusIcon = statusConfig[task.status as keyof typeof statusConfig].icon;
const statusProps = statusConfig[task.status as keyof typeof statusConfig];
return (
<Card key={task.id} className="hover:shadow-md transition-shadow">
<CardContent className="p-6">
<div className="flex items-center space-x-4">
{/* Thumbnail */}
<div className="relative w-24 h-16 bg-muted rounded-lg overflow-hidden flex-shrink-0">
{task.thumbnail ? (
<img
src={task.thumbnail}
alt={task.title}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<span className="text-2xl">🎬</span>
</div>
)}
{task.status === 'processing' && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
<RefreshCw className="h-4 w-4 text-white animate-spin" />
</div>
)}
</div>
{/* Task Info */}
<div className="flex-1 space-y-2">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-lg">{task.title}</h3>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{task.status === 'completed' && (
<>
<DropdownMenuItem>
<Play className="mr-2 h-4 w-4" />
Preview
</DropdownMenuItem>
<DropdownMenuItem>
<Download className="mr-2 h-4 w-4" />
Download
</DropdownMenuItem>
</>
)}
{task.status === 'failed' && (
<DropdownMenuItem>
<RefreshCw className="mr-2 h-4 w-4" />
Retry
</DropdownMenuItem>
)}
<DropdownMenuItem>
<Eye className="mr-2 h-4 w-4" />
View Details
</DropdownMenuItem>
<DropdownMenuItem className="text-destructive">
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex items-center space-x-4 text-sm">
<Badge
className={`${statusProps.bgColor} ${statusProps.color} border-none`}
>
<StatusIcon className="mr-1 h-3 w-3" />
{statusProps.label}
</Badge>
<div className="flex items-center text-muted-foreground">
<Clock className="mr-1 h-3 w-3" />
{task.duration}
</div>
<span className="text-muted-foreground">
{task.chapters} chapters
</span>
<span className="text-muted-foreground">
{task.actors.join(', ')}
</span>
</div>
{task.status === 'processing' && (
<div className="space-y-1">
<div className="flex justify-between text-xs text-muted-foreground">
<span>Processing...</span>
<span>{task.progress}%</span>
</div>
<Progress value={task.progress} className="h-1" />
</div>
)}
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>Started {getTimeSince(task.createdAt)}</span>
{task.completedAt && (
<span>Completed {formatDate(task.completedAt)}</span>
)}
</div>
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
{filteredTasks.length === 0 && (
<Card className="text-center py-12">
<CardContent>
<div className="space-y-4">
<div className="mx-auto w-16 h-16 rounded-full bg-muted flex items-center justify-center">
<Clock className="h-8 w-8 text-muted-foreground" />
</div>
<div className="space-y-2">
<h3 className="text-xl font-semibold">No tasks found</h3>
<p className="text-muted-foreground">
{searchQuery || statusFilter !== 'all'
? 'No tasks match your current filters. Try adjusting your search.'
: 'You haven\'t created any videos yet. Start by creating your first AI video!'
}
</p>
</div>
</div>
</CardContent>
</Card>
)}
</div>
);
}

View File

@ -6,16 +6,11 @@ import "./style/home-page2.css";
import { useRouter } from "next/navigation";
import { VideoScreenLayout } from '@/components/video-screen-layout';
import { VideoGridLayout } from '@/components/video-grid-layout';
import LiquidGlass from '@/plugins/liquid-glass';
import { motion } from "framer-motion";
import {
createScriptProject,
CreateScriptProjectRequest
} from '@/api/script_project';
import {
createScriptEpisode,
CreateScriptEpisodeRequest
} from '@/api/script_episode';
import {
ProjectTypeEnum,
ModeEnum,
@ -54,6 +49,7 @@ export function HomePage2() {
// 构建项目数据并调用API
const projectData: CreateScriptProjectRequest = {
title: "script default", // 默认剧本名称
project_type: projectType,
mode: ModeEnum.MANUAL,
resolution: ResolutionEnum.FULL_HD_1080P

View File

@ -1,7 +0,0 @@
export function ScriptToVideo() {
return (
<div>
<h1>Script To Video</h1>
</div>
);
}

View File

@ -1,197 +0,0 @@
"use client";
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { ArrowLeft } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { FilmstripStepper } from '@/components/filmstrip-stepper';
import { AISuggestionBar } from '@/components/ai-suggestion-bar';
import ScriptOverview from '@/components/pages/script-overview';
import StoryboardView from '@/components/pages/storyboard-view';
// 定义工作流程阶段
const WORKFLOW_STAGES = [
{
id: 'overview',
title: 'Script Overview',
subtitle: 'Script Overview',
description: 'Extract script structure and key elements'
},
{
id: 'storyboard',
title: 'Storyboard',
subtitle: 'Storyboard',
description: 'Visualize scene design and transitions'
},
{
id: 'character',
title: 'Character Design',
subtitle: 'Character Design',
description: 'Customize character appearance and personality'
},
{
id: 'post',
title: 'Post Production',
subtitle: 'Post Production',
description: 'Sound effects, music and special effects'
},
{
id: 'output',
title: 'Final Output',
subtitle: 'Final Output',
description: 'Preview and export works'
}
];
export default function ScriptWorkFlow() {
const router = useRouter();
const [currentStep, setCurrentStep] = useState('overview');
const [loading, setLoading] = useState(true);
// 根据当前步骤获取智能预设词条
const getSmartSuggestions = (stepId: string): string[] => {
const suggestions = {
overview: [
"Analyze core themes and emotions",
"Extract character relationship map",
"Generate scene and plot outline",
"Identify key turning points",
"Optimize story structure and pacing"
],
storyboard: [
"Design opening shot sequence",
"Plan transitions and visual effects",
"Generate key scene storyboards",
"Optimize shot language and rhythm",
"Add camera movement notes"
],
character: [
"Design protagonist appearance",
"Generate supporting character references",
"Create character relationship map",
"Add costume and prop designs",
"Optimize character actions"
],
post: [
"Plan sound and music style",
"Design visual effects solution",
"Add subtitles and graphics",
"Optimize color and lighting",
"Plan post-production workflow"
],
output: [
"Generate preview version",
"Optimize output parameters",
"Add opening and ending design",
"Export different formats",
"Create release plan"
]
};
return suggestions[stepId as keyof typeof suggestions] || [];
};
useEffect(() => {
// 模拟加载效果
const timer = setTimeout(() => {
setLoading(false);
}, 1500);
return () => clearTimeout(timer);
}, []);
// 处理步骤切换
const handleStepChange = async (stepId: string) => {
setLoading(true);
setCurrentStep(stepId);
// 模拟加载效果
await new Promise(resolve => setTimeout(resolve, 800));
setLoading(false);
};
// 处理 AI 建议点击
const handleSuggestionClick = (suggestion: string) => {
console.log('选择了建议:', suggestion);
// TODO: 处理建议点击逻辑
};
// 处理输入提交
const handleSubmit = (text: string) => {
console.log('提交了文本:', text);
// TODO: 处理文本提交逻辑
};
return (
<div className="h-full bg-[#0C0E11] text-white overflow-hidden">
{/* Navigation Tabs */}
<div className="fixed top-0 left-0 right-0 z-50">
{/* Glass Effect Background */}
<div className="absolute inset-0 bg-[#0C0E11]/80 backdrop-blur-md" />
{/* Bottom Border */}
<div className="absolute bottom-0 left-0 right-0 h-[1px] bg-white/10" />
{/* Navigation Content */}
<div className="relative h-16 flex items-center">
<div className="w-full max-w-screen-xl mx-auto px-6">
<div className="flex items-center justify-center gap-2">
{WORKFLOW_STAGES.map(stage => (
<motion.button
key={stage.id}
onClick={() => handleStepChange(stage.id)}
className={`
flex-shrink-0 px-6 py-2 rounded-lg transition-all duration-300
${currentStep === stage.id
? 'bg-white/10 text-white shadow-[0_0_15px_rgba(255,255,255,0.15)]'
: 'hover:bg-white/5 text-white/60 hover:text-white/80'
}
`}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<span className="text-sm font-medium whitespace-nowrap">{stage.title}</span>
</motion.button>
))}
</div>
</div>
</div>
</div>
{/* Main Content Area */}
<main className="pt-[3.5rem] px-6 h-[calc(100vh-9rem)] overflow-hidden">
<AnimatePresence mode="wait">
<motion.div
key={currentStep}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
className="w-full h-full overflow-hidden"
>
{loading ? (
<div className="space-y-4">
{[1, 2, 3].map(i => (
<div
key={i}
className="h-24 bg-white/5 rounded-lg animate-pulse"
/>
))}
</div>
) : (
<>
{currentStep === 'overview' && <ScriptOverview />}
{currentStep === 'storyboard' && <StoryboardView />}
</>
)}
</motion.div>
</AnimatePresence>
</main>
{/* AI Suggestion Bar */}
<AISuggestionBar
suggestions={getSmartSuggestions(currentStep)}
onSuggestionClick={handleSuggestionClick}
onSubmit={handleSubmit}
placeholder={`What would you like AI to help you with in the ${WORKFLOW_STAGES.find(stage => stage.id === currentStep)?.title} stage?`}
/>
</div>
);
}

View File

@ -1,783 +0,0 @@
"use client";
import { useState, useEffect, useRef } from 'react';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { ArrowLeft, ChevronDown, ChevronUp, Video, ListOrdered, Play, Loader2 } from 'lucide-react';
import { useRouter } from 'next/navigation';
import './style/video-to-video.css';
// 添加自定义滚动条样式
const scrollbarStyles = `
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
border-radius: 2px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}
`;
interface SceneVideo {
id: number;
video_url: string;
script: any;
}
export function VideoToVideo() {
const router = useRouter();
const [isUploading, setIsUploading] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const [videoUrl, setVideoUrl] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [loadingText, setLoadingText] = useState('Generating...');
const [generateObj, setGenerateObj] = useState<any>({
scripts: null,
frame_urls: null,
video_info: null,
scene_videos: null,
cut_video_url: null,
audio_video_url: null,
final_video_url: null
});
const [showAllFrames, setShowAllFrames] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const [showScrollNav, setShowScrollNav] = useState(false);
const [selectedVideoIndex, setSelectedVideoIndex] = useState<number | null>(null);
const videosContainerRef = useRef<HTMLDivElement>(null);
const scriptsContainerRef = useRef<HTMLDivElement>(null);
// 监听内容变化,自动滚动到底部
useEffect(() => {
if (containerRef.current && (generateObj.scripts || generateObj.frame_urls || generateObj.video_info)) {
setTimeout(() => {
containerRef.current?.scrollTo({
top: containerRef.current.scrollHeight,
behavior: 'smooth'
});
}, 100); // 给一个小延迟,确保内容已经渲染
}
}, [generateObj.scripts, generateObj.frame_urls, generateObj.video_info, generateObj.scene_videos, generateObj.cut_video_url, generateObj.audio_video_url, generateObj.final_video_url]);
// 计算每行可以显示的图片数量基于图片高度100px和容器宽度
const imagesPerRow = Math.floor(1080 / (100 * 16/9 + 8)); // 假设图片宽高比16:9间距8px
// 计算三行可以显示的最大图片数量
const maxVisibleImages = imagesPerRow * 3;
const handleUploadVideo = () => {
console.log('upload video');
// 打开文件选择器
const input = document.createElement('input');
input.type = 'file';
input.accept = 'video/*';
input.onchange = (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
setVideoUrl(URL.createObjectURL(file));
}
}
input.click();
}
const generateSences = async () => {
try {
generateObj.scene_videos = [];
const videoUrls = [
'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',
'https://cdn.qikongjian.com/videos/1750303377_8c3c4ca6-c4ea-4376-8583-de3afa5681d8_text_to_video_0.mp4',
'https://cdn.qikongjian.com/videos/1750162598_db834807-3f1c-4c52-8f50-b25396bd73ef_text_to_video_0.mp4'
];
setGenerateObj({...generateObj, scene_videos: []});
// 使用 Promise.all 和 Array.map 来处理异步操作
const promises = generateObj.scripts.map((element: any, index: number) => {
return new Promise<void>((resolveVideo) => {
setTimeout(() => {
generateObj.scene_videos.push({
id: index,
video_url: videoUrls[index],
script: element
});
setGenerateObj({...generateObj});
setLoadingText(`生成第 ${index + 1} 个分镜视频...`);
resolveVideo();
}, index * 3000); // 每个视频间隔3秒
});
});
// 等待所有视频生成完成
await Promise.all(promises);
} catch (error) {
console.error('生成分镜视频失败:', error);
throw error;
}
}
const handleCreateVideo = async () => {
try {
// 清空所有数据
setGenerateObj({
scripts: null,
frame_urls: null,
video_info: null,
scene_videos: null,
cut_video_url: null,
audio_video_url: null,
final_video_url: null
});
console.log('create video');
setIsLoading(true);
setIsExpanded(true);
// 提取帧
await new Promise(resolve => setTimeout(resolve, 3000));
setLoadingText('提取帧...');
// 生成帧
await new Promise(resolve => setTimeout(resolve, 3000));
generateObj.frame_urls = [
"https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000000.jpg/1750507510_tmphfb431oc_000000.jpg",
"https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000001.jpg/1750507511_tmphfb431oc_000001.jpg",
"https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000002.jpg/1750507511_tmphfb431oc_000002.jpg",
"https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000003.jpg/1750507511_tmphfb431oc_000003.jpg",
"https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000004.jpg/1750507512_tmphfb431oc_000004.jpg",
"https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000005.jpg/1750507512_tmphfb431oc_000005.jpg",
"https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000006.jpg/1750507513_tmphfb431oc_000006.jpg",
"https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000007.jpg/1750507514_tmphfb431oc_000007.jpg",
"https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000008.jpg/1750507515_tmphfb431oc_000008.jpg",
"https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000009.jpg/1750507515_tmphfb431oc_000009.jpg",
"https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000010.jpg/1750507516_tmphfb431oc_000010.jpg",
"https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000011.jpg/1750507516_tmphfb431oc_000011.jpg",
"https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000012.jpg/1750507516_tmphfb431oc_000012.jpg",
"https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000013.jpg/1750507517_tmphfb431oc_000013.jpg",
"https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000014.jpg/1750507517_tmphfb431oc_000014.jpg",
"https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000015.jpg/1750507517_tmphfb431oc_000015.jpg",
"https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000016.jpg/1750507517_tmphfb431oc_000016.jpg",
"https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000017.jpg/1750507517_tmphfb431oc_000017.jpg",
"https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000018.jpg/1750507518_tmphfb431oc_000018.jpg",
"https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000019.jpg/1750507520_tmphfb431oc_000019.jpg",
"https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000020.jpg/1750507521_tmphfb431oc_000020.jpg",
"https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000021.jpg/1750507523_tmphfb431oc_000021.jpg",
"https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000022.jpg/1750507523_tmphfb431oc_000022.jpg",
"https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000023.jpg/1750507524_tmphfb431oc_000023.jpg",
];
setGenerateObj({...generateObj});
setLoadingText('分析视频...');
// 生成视频信息
await new Promise(resolve => setTimeout(resolve, 6000));
generateObj.video_info = {
roles: [
{
name: '雪 (YUKI)',
core_identity: '一位接近二十岁或二十出头的年轻女性,东亚裔,拥有深色长发和刘海,五官柔和且富有表现力。',
avatar: 'https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000000.jpg/1750507510_tmphfb431oc_000000.jpg',
},{
name: '春 (HARU)',
core_identity: '一位接近二十岁或二十出头的年轻男性,东亚裔,拥有深色、发型整洁的中长发和深思的气质。',
avatar: 'https://smart-video-ai.oss-cn-beijing.aliyuncs.com/frames/d877fa43-4856-4acb-9a3b-627c28275343/frame_000000.jpg/1750507510_tmphfb431oc_000000.jpg',
}
],
sence: '叙事在两个不同的时间段展开。现在时空设定在一个安静的乡下小镇,冬季时被大雪覆盖。记忆则设定在春夏两季,一个日本高中的校园内外,天气温暖而晴朗。',
style: '电影感照片般逼真带有柔和、梦幻般的质感。其美学风格让人联想到日本的浪漫剧情片。现在时空的场景使用冷色、蓝色调的调色板而记忆序列则沐浴在温暖的黄金时刻光晕中。大量使用浅景深、微妙的镜头光晕以及平滑、富有情感的节奏。8K分辨率。'
};
setGenerateObj({...generateObj});
setLoadingText('提取分镜脚本...');
// 生成分镜脚本
await new Promise(resolve => setTimeout(resolve, 6000));
generateObj.scripts = [
{
shot: '面部特写,拉远至广角镜头。',
frame: '序列以雪YUKI面部的特写开场她闭着眼睛躺在一片纯净的雪地里。柔和的雪花轻轻飘落在她的黑发和苍白的皮肤上。摄像机缓慢拉远形成一幅令人惊叹的广角画面揭示出在黄昏时分她是在一片广阔、寂静、白雪覆盖的景观中的一个渺小、孤独的身影。',
atmosphere: '忧郁、宁静、寂静且寒冷。'
}, {
shot: '切至室内中景,随后是一个透过窗户的主观视角镜头。',
frame: '我们切到雪YUKI在一个舒适、光线温暖的卧室里醒来。她穿着一件舒适的毛衣从床上下来走向一扇窗户。她的呼吸在冰冷的玻璃上凝成雾气。她的主观视角镜头揭示了外面的雪景远处有一座红色的房子以及一封信被放入邮箱的记忆从而触发了一段闪回。',
atmosphere: '怀旧、温暖、内省。'
}, {
shot: '跟踪镜头,随后是一系列切出镜头和特写。',
frame: '记忆开始。一个跟踪镜头跟随着一群学生包括雪YUKI他们在飘落的樱花花瓣构成的华盖下走路上学。场景切到一个阳光普照的教室。雪YUKI穿着校服坐在她的课桌前害羞地瞥了一眼坐在前几排的春HARU。他仿佛感觉到她的凝视巧妙地转过头来。',
atmosphere: '充满青春气息、怀旧、温暖,带有一种萌芽的、未言明的浪漫感。'
}, {
shot: '静态中景,切到另一个中景,营造出共享空间的感觉。',
frame: '记忆转移到学校图书馆充满了金色的光束。一个中景镜头显示春HARU靠在一个书架上全神贯注地读一本书。然后摄像机切到雪YUKI她坐在附近的一张桌子旁专注于画架上的一幅小画当她感觉到他的存在时嘴唇上泛起一丝淡淡的、秘密的微笑。',
atmosphere: '平和、亲密、书卷气、温暖。'
}, {
shot: '雪YUKI的中景过渡到通过相机镜头的特写主观视角。',
frame: '在一个阳光明媚的运动日YUKI站在运动场边缘拿着一台老式相机。她举起相机镜头推进到她透过取景器看的眼睛的特写。我们切到她的主观视角除了站在运动场上、表情专注而坚定的春HARU之外整个世界都是失焦的。',
atmosphere: '充满活力、专注,一种投入的观察和遥远的钦佩感。'
},
];
setGenerateObj({...generateObj}); // 使用展开运算符创建新对象,确保触发更新
setLoadingText('生成分镜视频...');
// 生成分镜视频
await new Promise(resolve => setTimeout(resolve, 2000));
await generateSences();
setLoadingText('分镜剪辑...');
// 生成剪辑后的视频
await new Promise(resolve => setTimeout(resolve, 6000));
generateObj.cut_video_url = 'https://cdn.qikongjian.com/videos/1750385931_99a8fb42-af89-4ae9-841a-a49869f026bd_text_to_video_0.mp4';
setGenerateObj({...generateObj});
setLoadingText('口型同步...');
// 口型同步后生成视频
await new Promise(resolve => setTimeout(resolve, 6000));
generateObj.audio_video_url = 'https://cdn.qikongjian.com/videos/1750385931_99a8fb42-af89-4ae9-841a-a49869f026bd_text_to_video_0.mp4';
setGenerateObj({...generateObj});
setLoadingText('一致化处理...');
// 最终完成
await new Promise(resolve => setTimeout(resolve, 6000));
generateObj.final_video_url = 'https://cdn.qikongjian.com/videos/1750385931_99a8fb42-af89-4ae9-841a-a49869f026bd_text_to_video_0.mp4';
setGenerateObj({...generateObj});
setLoadingText('完成');
setIsLoading(false);
} catch (error) {
console.error('视频生成过程出错:', error);
setLoadingText('生成失败,请重试');
setIsLoading(false);
}
}
// 处理视频选中
const handleVideoSelect = (index: number) => {
setSelectedVideoIndex(index);
// 滚动脚本到对应位置
const scriptElement = document.getElementById(`script-${index}`);
scriptElement?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
};
// 处理脚本选中
const handleScriptSelect = (index: number) => {
setSelectedVideoIndex(index);
// 滚动视频到对应位置
const videoElement = document.getElementById(`video-${index}`);
videoElement?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
};
return (
<div
ref={containerRef}
className="container mx-auto overflow-auto custom-scrollbar"
style={isExpanded ? {height: 'calc(100vh - 12rem)'} : {height: 'calc(100vh - 20rem)'}}
>
{/* 展示创作详细过程 */}
{generateObj && (
<div className='video-creation-process-container mb-6'>
{generateObj.frame_urls && (
<div id='step-frame_urls' className='video-creation-process-item bg-white/5 rounded-lg p-4'>
<div className='video-creation-process-item-title mb-3'>
<span className='text-base font-medium'></span>
</div>
<div className='relative'>
<div className={`video-creation-process-item-content flex flex-wrap gap-2 ${!showAllFrames && 'max-h-[324px]'} overflow-hidden transition-all duration-300`}>
{generateObj.frame_urls.map((frame: string, index: number) => (
<div key={index} className="relative group">
<img
src={frame}
alt={`frame ${index + 1}`}
className='h-[100px] rounded-md object-cover transition-transform duration-200 group-hover:scale-105'
/>
<div className="absolute inset-0 bg-black/30 opacity-0 group-hover:opacity-100 transition-opacity duration-200 rounded-md flex items-center justify-center">
<span className="text-white/90 text-sm">Frame {index + 1}</span>
</div>
</div>
))}
</div>
{generateObj.frame_urls.length > maxVisibleImages && (
<div
className='absolute bottom-0 left-0 right-0 h-12 flex items-center justify-center bg-gradient-to-t from-black/20 to-transparent cursor-pointer'
onClick={() => setShowAllFrames(!showAllFrames)}
>
<div className='flex items-center gap-1 px-3 py-1 rounded-full bg-white/10 hover:bg-white/20 transition-colors'>
{showAllFrames ? (
<>
<ChevronUp className="w-4 h-4" />
<span className='text-sm'></span>
</>
) : (
<>
<ChevronDown className="w-4 h-4" />
<span className='text-sm'> ({generateObj.frame_urls.length} )</span>
</>
)}
</div>
</div>
)}
</div>
</div>
)}
{/* 视频信息 */}
{generateObj.video_info && (
<div id='step-video_info' className='video-creation-process-item bg-white/5 rounded-lg p-4'>
<div className='video-creation-process-item-title mb-3'>
<span className='text-base font-medium'></span>
</div>
{/* 展示:角色档案卡(头像、姓名、核心身份);场景;风格 */}
<div className='video-creation-process-item-content flex flex-col gap-6'>
{/* 角色档案卡 */}
<div className='space-y-4'>
<span className='inline-block text-sm font-medium text-blue-400/90 mb-1'></span>
<div className='flex flex-wrap gap-4'>
{generateObj.video_info.roles.map((role: any, index: number) => (
<div
key={index}
className='flex items-start gap-3 p-3 rounded-lg bg-white/[0.03] hover:bg-white/[0.05] transition-colors duration-200 min-w-[300px] max-w-[400px] group'
>
<div className='flex-shrink-0'>
<div className='w-[48px] h-[48px] rounded-full overflow-hidden border-2 border-white/10 group-hover:border-white/20 transition-colors duration-200'>
<img
src={role.avatar}
alt={role.name}
className='w-full h-full object-cover'
/>
</div>
</div>
<div className='flex-1 min-w-0'>
<div className='text-base font-medium mb-1 text-white/90'>{role.name}</div>
<div className='text-sm text-white/60 line-clamp-2'>{role.core_identity}</div>
</div>
</div>
))}
</div>
</div>
{/* 场景和风格 */}
<div className='space-y-4'>
<div className='video-creation-process-item-content-item-scene'>
<span className='inline-block text-sm font-medium text-blue-400/90 mb-1'></span>
<p className='text-sm text-white/80 leading-relaxed pl-1'>{generateObj.video_info.sence}</p>
</div>
<div className='video-creation-process-item-content-item-style'>
<span className='inline-block text-sm font-medium text-blue-400/90 mb-1'></span>
<p className='text-sm text-white/80 leading-relaxed pl-1'>{generateObj.video_info.style}</p>
</div>
</div>
</div>
</div>
)}
{/* 分镜脚本 */}
{generateObj.scripts && (
<div id='step-scripts' className='video-creation-process-item bg-white/5 rounded-lg p-4'>
<div className='video-creation-process-item-title mb-3'>
<span className='text-base font-medium'></span>
</div>
<div className='video-creation-process-item-content'>
<div className='flex flex-wrap gap-4'>
{generateObj.scripts.map((script: any, index: number) => (
<div
key={index}
className='flex-shrink-0 w-[360px] h-[400px] bg-white/[0.03] rounded-lg p-4 hover:bg-white/[0.05] transition-all duration-200 group'
>
{/* 序号 */}
<div className='flex items-center justify-between mb-3 pb-2 border-b border-white/10'>
<span className='text-lg font-medium text-blue-400/90'>Scene {index + 1}</span>
<div className='px-2 py-1 rounded-full bg-white/10 text-xs text-white/60'>
#{String(index + 1).padStart(2, '0')}
</div>
</div>
{/* 滚动内容区域 */}
<div className='h-[calc(100%-40px)] overflow-y-auto pr-2 space-y-4 custom-scrollbar'>
<div>
<span className='inline-block text-sm font-medium text-blue-400/90 mb-1'></span>
<p className='text-sm text-white/80 leading-relaxed'>{script.shot}</p>
</div>
<div>
<span className='inline-block text-sm font-medium text-blue-400/90 mb-1'></span>
<p className='text-sm text-white/80 leading-relaxed'>{script.frame}</p>
</div>
<div>
<span className='inline-block text-sm font-medium text-blue-400/90 mb-1'></span>
<p className='text-sm text-white/80 leading-relaxed'>{script.atmosphere}</p>
</div>
</div>
</div>
))}
</div>
</div>
</div>
)}
{/* 分镜视频 */}
{generateObj.scene_videos && (
<div id='step-scene_videos' className='video-creation-process-item bg-white/5 rounded-lg p-4'>
<div className='video-creation-process-item-title mb-3'>
<span className='text-sm font-medium'></span>
</div>
<div className='video-creation-process-item-content space-y-6'>
{/* 视频展示区 */}
<div
ref={videosContainerRef}
className='flex gap-4 overflow-x-auto pb-4 custom-scrollbar'
>
{generateObj.scripts.map((script: any, index: number) => {
const video = generateObj.scene_videos.find((v: any) => v.id === index);
const isSelected = selectedVideoIndex === index;
return (
<div
key={index}
id={`video-${index}`}
className={`flex-shrink-0 w-[320px] aspect-video rounded-lg overflow-hidden relative cursor-pointer transition-all duration-300
${isSelected ? 'ring-2 ring-blue-400 ring-offset-2 ring-offset-[#191B1E]' : 'hover:ring-2 hover:ring-white/20'}`}
onClick={() => handleVideoSelect(index)}
>
{video ? (
<>
<video
src={video.video_url}
className="w-full h-full object-cover"
controls={isSelected}
/>
{!isSelected && (
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
<Play className="w-8 h-8 text-white/90" />
</div>
)}
</>
) : (
<div className="w-full h-full bg-white/5 flex items-center justify-center">
<Loader2 className="w-8 h-8 text-white/40 animate-spin" />
</div>
)}
<div className="absolute top-2 right-2 px-2 py-1 rounded-full bg-black/60 text-xs text-white/80">
Scene {index + 1}
</div>
</div>
);
})}
</div>
{/* 脚本展示区 */}
<div
ref={scriptsContainerRef}
className='flex gap-4 overflow-x-auto pb-4 custom-scrollbar'
>
{generateObj.scripts.map((script: any, index: number) => {
const isSelected = selectedVideoIndex === index;
return (
<div
key={index}
id={`script-${index}`}
className={`flex-shrink-0 w-[320px] p-4 rounded-lg cursor-pointer transition-all duration-300
${isSelected ? 'bg-blue-400/10 border border-blue-400/30' : 'bg-white/5 hover:bg-white/10'}`}
onClick={() => handleScriptSelect(index)}
>
<div className="text-xs text-white/40 mb-2">Scene {index + 1}</div>
<div className="text-sm text-white/80 line-clamp-4">{script.frame}</div>
</div>
);
})}
</div>
</div>
</div>
)}
{/* 剪辑后的视频 */}
{generateObj.cut_video_url && (
<div id='step-cut_video' className='video-creation-process-item bg-white/5 rounded-lg p-4'>
<div className='video-creation-process-item-title mb-3'>
<span className='text-base font-medium'></span>
</div>
<div className='video-creation-process-item-content'>
<div className='flex flex-wrap gap-4'>
<div className='flex-shrink-0 w-full bg-white/[0.03] rounded-lg p-4 hover:bg-white/[0.05] transition-all duration-200 group'>
<video src={generateObj.cut_video_url} className="w-full h-full object-cover" controls />
</div>
</div>
</div>
</div>
)}
{/* 口型同步后的视频 */}
{generateObj.audio_video_url && (
<div id='step-audio_video' className='video-creation-process-item bg-white/5 rounded-lg p-4'>
<div className='video-creation-process-item-title mb-3'>
<span className='text-base font-medium'></span>
</div>
<div className='video-creation-process-item-content'>
<div className='flex flex-wrap gap-4'>
<div className='flex-shrink-0 w-full bg-white/[0.03] rounded-lg p-4 hover:bg-white/[0.05] transition-all duration-200 group'>
<video src={generateObj.audio_video_url} className="w-full h-full object-cover" controls />
</div>
</div>
</div>
</div>
)}
{/* 最终视频 */}
{generateObj.final_video_url && (
<div id='step-final_video' className='video-creation-process-item bg-white/5 rounded-lg p-4'>
<div className='video-creation-process-item-title mb-3'>
<span className='text-base font-medium'></span>
</div>
<div className='video-creation-process-item-content'>
<div className='flex flex-wrap gap-4'>
<div className='flex-shrink-0 w-full bg-white/[0.03] rounded-lg p-4 hover:bg-white/[0.05] transition-all duration-200 group'>
<video src={generateObj.final_video_url} className="w-full h-full object-cover" controls />
</div>
</div>
</div>
</div>
)}
</div>
)}
{/* 回滚条 */}
<div
className="fixed right-8 top-1/2 -translate-y-1/2 z-50"
onMouseEnter={() => setShowScrollNav(true)}
onMouseLeave={() => setShowScrollNav(false)}
>
{/* 悬浮按钮 */}
<button
className={`flex items-center justify-center w-12 h-12 rounded-full bg-gradient-to-br from-blue-400/20 to-blue-600/20 backdrop-blur-lg hover:from-blue-400/30 hover:to-blue-600/30 transition-all duration-300 group
${showScrollNav ? 'opacity-0 scale-90' : 'opacity-100 scale-100'}`}
>
<ListOrdered className="w-5 h-5 text-white/70 group-hover:text-white/90 transition-colors" />
</button>
{/* 展开的回滚导航 */}
<div className={`absolute right-0 top-1/2 -translate-y-1/2 transition-all duration-300 ease-out
${showScrollNav ? 'opacity-100 translate-x-0' : 'opacity-0 translate-x-4 pointer-events-none'}`}
>
<div className="flex items-center gap-4 bg-gradient-to-b from-black/30 to-black/10 backdrop-blur-lg rounded-l-2xl pl-6 pr-6 py-6">
{generateObj && (
<>
{/* 进度条背景 */}
<div className="absolute w-[3px] h-[280px] bg-gradient-to-b from-white/5 to-white/0 rounded-full left-8" />
{/* 动态进度条 */}
<div className="absolute w-[3px] rounded-full transition-all duration-500 ease-out left-8 overflow-hidden"
style={{
height: generateObj.final_video_url ? '280px' :
generateObj.audio_video_url ? '240px' :
generateObj.cut_video_url ? '200px' :
generateObj.scene_videos ? '160px' :
generateObj.scripts ? '120px' :
generateObj.video_info ? '80px' :
generateObj.frame_urls ? '40px' : '0px',
top: '24px',
background: 'linear-gradient(180deg, rgba(96,165,250,0.7) 0%, rgba(96,165,250,0.3) 100%)',
boxShadow: '0 0 20px rgba(96,165,250,0.3)'
}}
/>
{/* 步骤按钮 */}
<div className="relative flex flex-col justify-between h-[280px] py-2">
{generateObj.frame_urls && (
<button
onClick={() => {
const element = document.getElementById('step-frame_urls');
element?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}}
className="group flex items-center gap-3 -ml-1"
>
<span className="text-xs text-white/60 group-hover:text-white/90 transition-colors duration-200 min-w-[5rem] text-right"></span>
<div className="relative w-3 h-3">
<div className="absolute inset-0 bg-blue-400/80 rounded-full group-hover:scale-150 group-hover:bg-blue-400 transition-all duration-300" />
<div className="absolute inset-0 bg-blue-400/20 rounded-full animate-ping group-hover:animate-none" />
</div>
</button>
)}
{generateObj.video_info && (
<button
onClick={() => {
const element = document.getElementById('step-video_info');
element?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}}
className="group flex items-center gap-3 -ml-1"
>
<span className="text-xs text-white/60 group-hover:text-white/90 transition-colors duration-200 min-w-[5rem] text-right"></span>
<div className="relative w-3 h-3">
<div className="absolute inset-0 bg-blue-400/80 rounded-full group-hover:scale-150 group-hover:bg-blue-400 transition-all duration-300" />
<div className="absolute inset-0 bg-blue-400/20 rounded-full animate-ping group-hover:animate-none" />
</div>
</button>
)}
{generateObj.scripts && (
<button
onClick={() => {
const element = document.getElementById('step-scripts');
element?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}}
className="group flex items-center gap-3 -ml-1"
>
<span className="text-xs text-white/60 group-hover:text-white/90 transition-colors duration-200 min-w-[5rem] text-right"></span>
<div className="relative w-3 h-3">
<div className="absolute inset-0 bg-blue-400/80 rounded-full group-hover:scale-150 group-hover:bg-blue-400 transition-all duration-300" />
<div className="absolute inset-0 bg-blue-400/20 rounded-full animate-ping group-hover:animate-none" />
</div>
</button>
)}
{generateObj.scene_videos && (
<button
onClick={() => {
const element = document.getElementById('step-scene_videos');
element?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}}
className="group flex items-center gap-3 -ml-1"
>
<span className="text-xs text-white/60 group-hover:text-white/90 transition-colors duration-200 min-w-[5rem] text-right"></span>
<div className="relative w-3 h-3">
<div className="absolute inset-0 bg-blue-400/80 rounded-full group-hover:scale-150 group-hover:bg-blue-400 transition-all duration-300" />
<div className="absolute inset-0 bg-blue-400/20 rounded-full animate-ping group-hover:animate-none" />
</div>
</button>
)}
{generateObj.cut_video_url && (
<button
onClick={() => {
const element = document.getElementById('step-cut_video');
element?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}}
className="group flex items-center gap-3 -ml-1"
>
<span className="text-xs text-white/60 group-hover:text-white/90 transition-colors duration-200 min-w-[5rem] text-right"></span>
<div className="relative w-3 h-3">
<div className="absolute inset-0 bg-blue-400/80 rounded-full group-hover:scale-150 group-hover:bg-blue-400 transition-all duration-300" />
<div className="absolute inset-0 bg-blue-400/20 rounded-full animate-ping group-hover:animate-none" />
</div>
</button>
)}
{generateObj.audio_video_url && (
<button
onClick={() => {
const element = document.getElementById('step-audio_video');
element?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}}
className="group flex items-center gap-3 -ml-1"
>
<span className="text-xs text-white/60 group-hover:text-white/90 transition-colors duration-200 min-w-[5rem] text-right"></span>
<div className="relative w-3 h-3">
<div className="absolute inset-0 bg-blue-400/80 rounded-full group-hover:scale-150 group-hover:bg-blue-400 transition-all duration-300" />
<div className="absolute inset-0 bg-blue-400/20 rounded-full animate-ping group-hover:animate-none" />
</div>
</button>
)}
{generateObj.final_video_url && (
<button
onClick={() => {
const element = document.getElementById('step-final_video');
element?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}}
className="group flex items-center gap-3 -ml-1"
>
<span className="text-xs text-white/60 group-hover:text-white/90 transition-colors duration-200 min-w-[5rem] text-right"></span>
<div className="relative w-3 h-3">
<div className="absolute inset-0 bg-blue-400/80 rounded-full group-hover:scale-150 group-hover:bg-blue-400 transition-all duration-300" />
<div className="absolute inset-0 bg-blue-400/20 rounded-full animate-ping group-hover:animate-none" />
</div>
</button>
)}
</div>
</>
)}
</div>
</div>
</div>
{/* 工具栏 */}
<div className='video-tool-component relative w-[1080px]'>
<div className='video-storyboard-tools grid gap-4 rounded-[20px] bg-[#fff3] backdrop-blur-[15px]'>
{isExpanded ? (
<div className='absolute top-0 bottom-0 left-0 right-0 z-[9] grid justify-items-center place-content-center rounded-[20px] bg-[#191B1E] bg-opacity-[0.3] backdrop-blur-[1.5px] cursor-pointer' onClick={() => setIsExpanded(false)}>
{/* 图标 展开按钮 */}
<ChevronUp className='w-4 h-4' />
<span className='text-sm'>Click to create</span>
</div>
) : (
<div className='absolute top-[-8px] left-[50%] z-[10] flex items-center justify-center w-[46px] h-4 rounded-[15px] bg-[#fff3] translate-x-[-50%] cursor-pointer hover:bg-[#ffffff1a]' onClick={() => setIsExpanded(true)}>
{/* 图标 折叠按钮 */}
<ChevronDown className='w-4 h-4' />
</div>
)}
<div className={`flex-shrink-0 p-4 overflow-hidden transition-all duration-300 ${isExpanded ? 'h-[16px]' : 'h-[162px]'}`}>
<div className='video-creation-tool-container flex flex-col gap-4'>
<div className='relative flex items-center gap-4'>
<div className='relative w-[72px] rounded-[6px] overflow-hidden cursor-pointer ant-dropdown-trigger' onClick={handleUploadVideo}>
<div className='relative flex items-center justify-center w-[72px] h-[72px] rounded-[6px 6px 0 0] bg-white/[0.05] transition-all overflow-hidden hover:bg-white/[0.10]'>
{/* 图标 添加视频 */}
<Video className='w-4 h-4' />
</div>
<div className='w-full h-[22px] flex items-center justify-center rounded-[0 0 6px 6px] bg-white/[0.03]'>
<span className='text-xs cursor-[inherit]'>Add Video</span>
</div>
</div>
{videoUrl && (
<div className='relative w-[72px] rounded-[6px] overflow-hidden cursor-pointer ant-dropdown-trigger'>
<div className='relative flex items-center justify-center w-[72px] h-[72px] rounded-[6px 6px 0 0] bg-white/[0.05] transition-all overflow-hidden hover:bg-white/[0.10]'>
<video src={videoUrl} className='w-full h-full object-cover' />
</div>
</div>
)}
</div>
</div>
<div className='flex gap-3 justify-end'>
<div className='flex items-center gap-3'>
<div className='disabled tool-submit-button'>Stop</div>
<div className={`tool-submit-button ${videoUrl ? '' : 'disabled'}`} onClick={handleCreateVideo}>Create</div>
</div>
</div>
</div>
</div>
</div>
{/* Loading动画 */}
{isLoading && (
<div className='mt-8 flex justify-center'>
<div className='relative'>
{/* 外圈动画 */}
<div className='w-[40px] h-[40px] rounded-full bg-white/[0.05] flex items-center justify-center animate-bounce'>
{/* 中圈动画 */}
<div className='w-[20px] h-[20px] rounded-full bg-white/[0.05] flex items-center justify-center animate-pulse'>
{/* 内圈动画 */}
<div className='w-[10px] h-[10px] rounded-full bg-white/[0.05] flex items-center justify-center animate-ping'>
</div>
</div>
</div>
{/* Loading文字 */}
<div className='absolute top-[50px] left-1/2 -translate-x-1/2 whitespace-nowrap'>
<span className='text-white/70 text-sm animate-pulse inline-block'>{loadingText}</span>
<span className='inline-block ml-1 animate-bounce'>
<span className='inline-block animate-bounce delay-100'>.</span>
<span className='inline-block animate-bounce delay-200'>.</span>
<span className='inline-block animate-bounce delay-300'>.</span>
</span>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -201,8 +201,6 @@ export default function WorkFlow() {
taskProgress: 0,
mode: 'auto', // 全自动模式、人工干预模式
resolution: '1080p', // 1080p、2160p
taskCreatedAt: new Date().toISOString(),
taskUpdatedAt: new Date().toISOString(),
};
return data;
}

View File

@ -0,0 +1,365 @@
'use client';
import React, { useRef, useEffect, useState } from 'react';
import { motion } from 'framer-motion';
import { Play, Pause, Volume2, VolumeX, AlertCircle } from 'lucide-react';
import { cn } from '@/public/lib/utils';
import WaveSurfer from 'wavesurfer.js';
interface AudioVisualizerProps {
audioUrl?: string;
title?: string;
volume?: number;
isActive?: boolean;
className?: string;
onVolumeChange?: (volume: number) => void;
}
// 模拟波形数据生成器
const generateMockWaveform = (width = 300, height = 50) => {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d')!;
// 绘制模拟波形
ctx.fillStyle = '#6b7280';
const barWidth = 2;
const gap = 1;
const numBars = Math.floor(width / (barWidth + gap));
for (let i = 0; i < numBars; i++) {
const x = i * (barWidth + gap);
const barHeight = Math.random() * height * 0.8 + height * 0.1;
const y = (height - barHeight) / 2;
ctx.fillRect(x, y, barWidth, barHeight);
}
return canvas.toDataURL();
};
export function AudioVisualizer({
audioUrl = '/audio/demo.mp3',
title = 'Background Music',
volume = 75,
isActive = false,
className,
onVolumeChange
}: AudioVisualizerProps) {
const waveformRef = useRef<HTMLDivElement>(null);
const wavesurfer = useRef<WaveSurfer | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(false);
const [duration, setDuration] = useState(120); // 默认2分钟
const [currentTime, setCurrentTime] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [hasError, setHasError] = useState(false);
const [mockWaveformUrl, setMockWaveformUrl] = useState<string>('');
// 生成模拟波形
useEffect(() => {
if (waveformRef.current) {
const width = waveformRef.current.clientWidth || 300;
const mockUrl = generateMockWaveform(width, 50);
setMockWaveformUrl(mockUrl);
}
}, [isActive]);
// 初始化 Wavesurfer
useEffect(() => {
if (!waveformRef.current) return;
// 创建 wavesurfer 实例
wavesurfer.current = WaveSurfer.create({
container: waveformRef.current,
waveColor: isActive ? '#3b82f6' : '#6b7280',
progressColor: isActive ? '#1d4ed8' : '#374151',
cursorColor: '#ffffff',
barWidth: 2,
barRadius: 3,
responsive: true,
height: 50,
normalize: true,
backend: 'WebAudio',
mediaControls: false,
});
// 尝试加载音频,如果失败则使用模拟数据
setIsLoading(true);
setHasError(false);
wavesurfer.current.load(audioUrl).catch(() => {
console.warn('音频文件加载失败,使用模拟数据');
setHasError(true);
setIsLoading(false);
// 如果加载失败,创建空的音频上下文用于演示
if (wavesurfer.current) {
wavesurfer.current.empty();
// 创建模拟的峰值数据
const peaks = Array.from({ length: 1000 }, () => Math.random() * 2 - 1);
wavesurfer.current.loadDecodedBuffer({
getChannelData: () => new Float32Array(peaks),
length: peaks.length,
sampleRate: 44100,
numberOfChannels: 1,
duration: 120
} as any);
}
});
// 事件监听
wavesurfer.current.on('ready', () => {
setDuration(wavesurfer.current?.getDuration() || 120);
setIsLoading(false);
if (wavesurfer.current) {
wavesurfer.current.setVolume(volume / 100);
}
});
wavesurfer.current.on('audioprocess', () => {
setCurrentTime(wavesurfer.current?.getCurrentTime() || 0);
});
wavesurfer.current.on('seek', () => {
setCurrentTime(wavesurfer.current?.getCurrentTime() || 0);
});
wavesurfer.current.on('play', () => {
setIsPlaying(true);
});
wavesurfer.current.on('pause', () => {
setIsPlaying(false);
});
wavesurfer.current.on('finish', () => {
setIsPlaying(false);
setCurrentTime(0);
});
wavesurfer.current.on('error', (error) => {
console.warn('Wavesurfer error:', error);
setHasError(true);
setIsLoading(false);
});
return () => {
if (wavesurfer.current) {
wavesurfer.current.destroy();
}
};
}, [audioUrl]);
// 更新波形颜色当 isActive 改变时
useEffect(() => {
if (wavesurfer.current && !hasError) {
wavesurfer.current.setOptions({
waveColor: isActive ? '#3b82f6' : '#6b7280',
progressColor: isActive ? '#1d4ed8' : '#374151',
});
}
}, [isActive, hasError]);
// 更新音量
useEffect(() => {
if (wavesurfer.current && !hasError) {
wavesurfer.current.setVolume(isMuted ? 0 : volume / 100);
}
}, [volume, isMuted, hasError]);
// 模拟播放进度(当使用模拟数据时)
useEffect(() => {
let interval: NodeJS.Timeout;
if (isPlaying && hasError) {
interval = setInterval(() => {
setCurrentTime(prev => {
const next = prev + 1;
if (next >= duration) {
setIsPlaying(false);
return 0;
}
return next;
});
}, 1000);
}
return () => clearInterval(interval);
}, [isPlaying, hasError, duration]);
const togglePlayPause = () => {
if (hasError) {
// 模拟播放/暂停
setIsPlaying(!isPlaying);
} else if (wavesurfer.current) {
wavesurfer.current.playPause();
}
};
const toggleMute = () => {
setIsMuted(!isMuted);
};
const formatTime = (time: number) => {
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
};
const progressPercentage = duration > 0 ? (currentTime / duration) * 100 : 0;
return (
<motion.div
className={cn(
'p-4 rounded-lg border-2 bg-white/5 transition-all duration-300',
isActive ? 'border-blue-500 bg-blue-500/10' : 'border-white/20',
className
)}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.2 }}
>
<div className="space-y-3">
{/* 标题和音量 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 rounded-full bg-white/10">
{hasError ? (
<AlertCircle className="w-4 h-4 text-yellow-500" />
) : (
<Volume2 className="w-4 h-4" />
)}
</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>
</div>
</div>
<div className="text-sm text-white/70">{volume}%</div>
</div>
{/* 波形可视化 */}
<div className="relative">
{hasError ? (
// 显示模拟波形图片
<div className="w-full h-[50px] bg-white/5 rounded flex items-center justify-center relative overflow-hidden">
{mockWaveformUrl && (
<img
src={mockWaveformUrl}
alt="Audio waveform"
className="w-full h-full object-cover opacity-70"
/>
)}
{/* 进度条覆盖层 */}
<div
className="absolute left-0 top-0 h-full bg-blue-500/30 transition-all duration-300"
style={{ width: `${progressPercentage}%` }}
/>
{/* 播放游标 */}
<div
className="absolute top-0 bottom-0 w-0.5 bg-white transition-all duration-300"
style={{ left: `${progressPercentage}%` }}
/>
</div>
) : (
<div
ref={waveformRef}
className={cn(
"w-full transition-opacity duration-300",
isLoading ? "opacity-50" : "opacity-100"
)}
/>
)}
{isLoading && !hasError && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
</div>
)}
</div>
{/* 控制栏 */}
<div className="flex items-center gap-3">
{/* 播放/暂停按钮 */}
<motion.button
className="p-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors"
onClick={togglePlayPause}
disabled={isLoading && !hasError}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
{isPlaying ? (
<Pause className="w-4 h-4" />
) : (
<Play className="w-4 h-4" />
)}
</motion.button>
{/* 静音按钮 */}
<motion.button
className="p-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors"
onClick={toggleMute}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
{isMuted ? (
<VolumeX className="w-4 h-4" />
) : (
<Volume2 className="w-4 h-4" />
)}
</motion.button>
{/* 时间显示 */}
<div className="flex-1 text-center">
<span className="text-xs text-white/70">
{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>
{/* 播放状态指示器 */}
{isPlaying && (
<motion.div
className="h-0.5 bg-gradient-to-r from-blue-500 via-purple-500 to-blue-500 rounded-full"
initial={{ scaleX: 0 }}
animate={{ scaleX: [0, 1, 0] }}
transition={{
duration: 2,
repeat: Infinity,
ease: "easeInOut"
}}
/>
)}
{/* 错误提示 */}
{hasError && (
<div className="text-xs text-yellow-500/80 text-center">
- 使
</div>
)}
</div>
</motion.div>
);
}

View File

@ -0,0 +1,514 @@
'use client';
import React, { useState, useRef, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Play, Pause, Volume2, VolumeX, Upload, Library, Wand2, ZoomIn, RotateCw, Info, ChevronDown } from 'lucide-react';
import { cn } from '@/public/lib/utils';
import { AudioVisualizer } from './audio-visualizer';
interface MediaPropertiesModalProps {
isOpen: boolean;
onClose: () => void;
taskSketch: any[];
currentSketchIndex: number;
onSketchSelect: (index: number) => void;
}
export function MediaPropertiesModal({
isOpen,
onClose,
taskSketch = [],
currentSketchIndex = 0,
onSketchSelect
}: MediaPropertiesModalProps) {
const [activeTab, setActiveTab] = useState<'media' | 'audio'>('media');
const [selectedSketchIndex, setSelectedSketchIndex] = useState(currentSketchIndex);
const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(false);
const [progress, setProgress] = useState(0);
const [trimAutomatically, setTrimAutomatically] = useState(false);
const [audioVolume, setAudioVolume] = useState(75);
const videoPlayerRef = useRef<HTMLVideoElement>(null);
const thumbnailsRef = useRef<HTMLDivElement>(null);
// 当弹窗打开时,同步当前选中的分镜
useEffect(() => {
if (isOpen) {
setSelectedSketchIndex(currentSketchIndex);
}
}, [isOpen, currentSketchIndex]);
// 确保 taskSketch 是数组
const sketches = Array.isArray(taskSketch) ? taskSketch : [];
// 模拟媒体属性数据
const currentSketch = sketches[selectedSketchIndex];
const mediaProperties = {
duration: '00m : 10s : 500ms / 00m : 17s : 320ms',
trim: { start: '0.0s', end: '10.5s' },
centerPoint: { x: 0.5, y: 0.5 },
zoom: 100,
rotation: 0,
transition: 'Auto',
script: 'This part of the script is 21.00 seconds long.'
};
const audioProperties = {
sfxName: 'Background Music',
sfxVolume: audioVolume
};
// 自动滚动到选中项
useEffect(() => {
if (thumbnailsRef.current && isOpen) {
const thumbnailContainer = thumbnailsRef.current;
const thumbnailWidth = thumbnailContainer.children[0]?.clientWidth ?? 0;
const thumbnailGap = 16;
const thumbnailScrollPosition = (thumbnailWidth + thumbnailGap) * selectedSketchIndex;
thumbnailContainer.scrollTo({
left: thumbnailScrollPosition - thumbnailContainer.clientWidth / 2 + thumbnailWidth / 2,
behavior: 'smooth'
});
}
}, [selectedSketchIndex, isOpen]);
// 视频播放控制
useEffect(() => {
if (videoPlayerRef.current) {
if (isPlaying) {
videoPlayerRef.current.play().catch(() => {
setIsPlaying(false);
});
} else {
videoPlayerRef.current.pause();
}
}
}, [isPlaying, selectedSketchIndex]);
// 更新进度条
const handleTimeUpdate = () => {
if (videoPlayerRef.current) {
const progress = (videoPlayerRef.current.currentTime / videoPlayerRef.current.duration) * 100;
setProgress(progress);
}
};
const handleSketchSelect = (index: number) => {
setSelectedSketchIndex(index);
setProgress(0);
};
if (sketches.length === 0) {
return null;
}
return (
<AnimatePresence>
{isOpen && (
<>
{/* 背景遮罩 */}
<motion.div
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
/>
{/* 弹窗内容 */}
<div className="fixed inset-x-0 bottom-0 z-50 flex justify-center">
<motion.div
className="w-[66%] min-w-[800px] bg-[#1a1b1e] rounded-t-2xl overflow-hidden"
initial={{ y: '100%' }}
animate={{ y: 0 }}
exit={{ y: '100%' }}
transition={{
type: 'spring',
damping: 25,
stiffness: 200,
}}
>
{/* 标题栏 */}
<div className="flex items-center justify-between p-4 border-b border-white/10">
<div className="flex items-center gap-2">
<button
className="p-1 hover:bg-white/10 rounded-full transition-colors"
onClick={onClose}
>
<ChevronDown className="w-5 h-5" />
</button>
<h2 className="text-lg font-medium">Media Properties</h2>
</div>
</div>
<div className="h-[80vh] flex flex-col">
{/* 上部:分镜视频列表 */}
<div className="p-4 border-b border-white/10">
<div
ref={thumbnailsRef}
className="flex gap-4 overflow-x-auto pb-2 pt-2 hide-scrollbar"
>
{sketches.map((sketch, index) => (
<motion.div
key={sketch.id || index}
className={cn(
'relative flex-shrink-0 w-32 aspect-video rounded-lg overflow-hidden cursor-pointer',
selectedSketchIndex === index ? 'ring-2 ring-blue-500' : 'hover:ring-2 hover:ring-blue-500/50'
)}
onClick={() => handleSketchSelect(index)}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<video
src={sketch.url}
className="w-full h-full object-cover"
muted
loop
playsInline
onMouseEnter={(e) => e.currentTarget.play()}
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>
</div>
</motion.div>
))}
</div>
</div>
{/* 下部:主要内容区域 */}
<div className="flex-1 flex overflow-hidden">
{/* 左侧 2/3编辑选项 */}
<div className="flex-[2] p-4 border-r border-white/10 overflow-y-auto">
{/* Media/Audio & SFX 切换按钮 */}
<div className="flex gap-2 mb-6">
<motion.button
className={cn(
'px-4 py-2 rounded-lg transition-colors',
activeTab === 'media'
? 'bg-blue-500 text-white'
: 'bg-white/10 text-white/70 hover:bg-white/20'
)}
onClick={() => setActiveTab('media')}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
Media
</motion.button>
<motion.button
className={cn(
'px-4 py-2 rounded-lg transition-colors',
activeTab === 'audio'
? 'bg-blue-500 text-white'
: 'bg-white/10 text-white/70 hover:bg-white/20'
)}
onClick={() => setActiveTab('audio')}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
Audio & SFX
</motion.button>
</div>
{/* 内容区域 */}
<div className="space-y-4">
{activeTab === 'media' ? (
<>
{/* Duration - 只读 */}
<div className="flex items-center justify-between py-2">
<label className="text-sm font-medium text-white/80">Duration</label>
<span className="text-sm text-white/70">{mediaProperties.duration}</span>
</div>
{/* Trim - 可编辑 */}
<div className="flex items-center justify-between py-2">
<label className="text-sm font-medium text-white/80">Trim</label>
<div className="flex items-center gap-3">
<span className="text-sm text-white/60">from</span>
<input
type="text"
value={mediaProperties.trim.start}
placeholder="0.0s"
className="w-16 px-2 py-1 bg-white/10 rounded text-sm text-white text-center focus:outline-none focus:border-blue-500"
/>
<span className="text-sm text-white/60">to</span>
<input
type="text"
value={mediaProperties.trim.end}
placeholder="10.5s"
className="w-16 px-2 py-1 bg-white/10 rounded text-sm text-white text-center focus:outline-none focus:border-blue-500"
/>
</div>
</div>
{/* Center point - 可编辑 */}
<div className="flex items-center justify-between py-2">
<label className="text-sm font-medium text-white/80">Center point</label>
<div className="flex items-center gap-2">
<input
type="number"
value={mediaProperties.centerPoint.x}
min="0"
max="1"
step="0.1"
className="w-14 px-2 py-1 bg-white/10 rounded text-sm text-white text-center focus:outline-none focus:border-blue-500"
/>
<span className="text-white/40">X</span>
<input
type="number"
value={mediaProperties.centerPoint.y}
min="0"
max="1"
step="0.1"
className="w-14 px-2 py-1 bg-white/10 rounded text-sm text-white text-center focus:outline-none focus:border-blue-500"
/>
<span className="text-white/40">Y</span>
</div>
</div>
{/* Zoom & Rotation - 可编辑 */}
<div className="flex items-center justify-between py-2">
<label className="text-sm font-medium text-white/80">Zoom & Rotation</label>
<div className="flex items-center gap-3">
<div className="flex items-center gap-1">
<ZoomIn className="w-4 h-4 text-white/60" />
<input
type="number"
value={mediaProperties.zoom}
min="10"
max="500"
className="w-14 px-2 py-1 bg-white/10 rounded text-sm text-white text-center focus:outline-none focus:border-blue-500"
/>
<span className="text-xs text-white/60">%</span>
</div>
<div className="flex items-center gap-1">
<RotateCw className="w-4 h-4 text-white/60" />
<input
type="number"
value={mediaProperties.rotation}
min="-360"
max="360"
className="w-14 px-2 py-1 bg-white/10 rounded text-sm text-white text-center focus:outline-none focus:border-blue-500"
/>
<span className="text-xs text-white/60">°</span>
</div>
</div>
</div>
{/* Transition - 可编辑 */}
<div className="flex items-center justify-between py-2">
<div className="flex items-center gap-1">
<label className="text-sm font-medium text-white/80">Transition</label>
<Info className="w-3 h-3 text-white/40" />
</div>
<select
value={mediaProperties.transition}
className="px-3 py-1 bg-white/10 rounded text-sm text-white focus:outline-none focus:border-blue-500 appearance-none cursor-pointer"
style={{
backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3e%3c/svg%3e")`,
backgroundPosition: 'right 0.5rem center',
backgroundRepeat: 'no-repeat',
backgroundSize: '1.5em 1.5em',
paddingRight: '2.5rem'
}}
>
<option value="Auto">Auto</option>
<option value="淡入淡出"></option>
<option value="滑动"></option>
<option value="缩放"></option>
<option value="旋转"></option>
</select>
</div>
{/* Script - 只读 */}
<div className="py-2">
<label className="block text-sm font-medium text-white/80 mb-2">Script</label>
<p className="text-sm text-white/70 leading-relaxed">
{mediaProperties.script}
</p>
</div>
</>
) : (
<>
{/* SFX name - 只读 */}
<div className="flex items-center justify-between py-2">
<label className="text-sm font-medium text-white/80">SFX name</label>
<span className="text-sm text-white/70">{audioProperties.sfxName}</span>
</div>
{/* SFX volume - 可编辑 */}
<div className="py-2">
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium text-white/80">SFX volume</label>
<span className="text-sm text-white/70">{audioProperties.sfxVolume}%</span>
</div>
<input
type="range"
min="0"
max="100"
value={audioProperties.sfxVolume}
onChange={(e) => setAudioVolume(parseInt(e.target.value))}
className="w-full h-2 bg-white/20 rounded-lg appearance-none cursor-pointer slider"
style={{
background: `linear-gradient(to right, #3b82f6 0%, #3b82f6 ${audioProperties.sfxVolume}%, rgba(255,255,255,0.2) ${audioProperties.sfxVolume}%, rgba(255,255,255,0.2) 100%)`
}}
/>
</div>
{/* Replace audio */}
<div className="py-2">
<label className="block text-sm font-medium text-white/80 mb-3">Replace audio</label>
<div className="flex flex-wrap gap-2">
<motion.button
className="flex items-center gap-2 px-3 py-2 bg-white/10 hover:bg-white/20 rounded text-sm transition-colors"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<Upload className="w-4 h-4" />
Replace audio
</motion.button>
<motion.button
className="flex items-center gap-2 px-3 py-2 bg-white/10 hover:bg-white/20 rounded text-sm transition-colors"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<Library className="w-4 h-4" />
Stock SFX
</motion.button>
<motion.button
className="flex items-center gap-2 px-3 py-2 bg-white/10 hover:bg-white/20 rounded text-sm transition-colors"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<Wand2 className="w-4 h-4" />
Generate SFX
</motion.button>
</div>
</div>
</>
)}
</div>
</div>
{/* 右侧 1/3预览区域 */}
<div className="flex-1 p-4">
<div className="space-y-4">
{/* 视频预览 */}
<div className={cn(
'aspect-video rounded-lg overflow-hidden relative group border-2',
activeTab === 'media' ? 'border-blue-500' : 'border-white/20'
)}>
<video
ref={videoPlayerRef}
src={sketches[selectedSketchIndex]?.url}
className="w-full h-full object-cover"
loop
playsInline
muted={isMuted}
onTimeUpdate={handleTimeUpdate}
/>
{/* 视频控制层 */}
<div className="absolute inset-0 bg-black/30 opacity-0 group-hover:opacity-100 transition-opacity">
<div className="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/60 to-transparent">
{/* 进度条 */}
<div className="w-full h-1 bg-white/20 rounded-full mb-2 cursor-pointer"
onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const percentage = (x / rect.width) * 100;
if (videoPlayerRef.current) {
videoPlayerRef.current.currentTime = (percentage / 100) * videoPlayerRef.current.duration;
}
}}
>
<motion.div
className="h-full bg-blue-500 rounded-full"
style={{ width: `${progress}%` }}
/>
</div>
{/* 控制按钮 */}
<div className="flex items-center gap-2">
<motion.button
className="p-1 rounded-full hover:bg-white/10"
onClick={() => setIsPlaying(!isPlaying)}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
{isPlaying ? (
<Pause className="w-4 h-4" />
) : (
<Play className="w-4 h-4" />
)}
</motion.button>
<motion.button
className="p-1 rounded-full hover:bg-white/10"
onClick={() => setIsMuted(!isMuted)}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
{isMuted ? (
<VolumeX className="w-4 h-4" />
) : (
<Volume2 className="w-4 h-4" />
)}
</motion.button>
<span className="text-xs ml-auto">00:21</span>
</div>
</div>
</div>
</div>
{/* 音频预览 */}
<AudioVisualizer
audioUrl="/audio/demo.mp3" // 可以根据选中的分镜动态改变
title={audioProperties.sfxName}
volume={audioVolume}
isActive={activeTab === 'audio'}
onVolumeChange={setAudioVolume}
/>
</div>
</div>
</div>
</div>
{/* 底部操作栏 */}
<div className="p-4 border-t border-white/10 bg-black/20">
<div className="flex justify-end gap-3">
<motion.button
className="px-4 py-2 rounded-lg bg-white/10 text-white hover:bg-white/20 transition-colors"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={() => {
// TODO: 实现重置逻辑
console.log('Reset clicked');
}}
>
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 }}
onClick={() => {
// TODO: 实现应用逻辑
console.log('Apply clicked');
onSketchSelect(selectedSketchIndex);
onClose();
}}
>
Apply
</motion.button>
</div>
</div>
</motion.div>
</div>
</>
)}
</AnimatePresence>
);
}

View File

@ -6,6 +6,7 @@ import { Trash2, RefreshCw, Play, Pause, Volume2, VolumeX, Upload, Library, Wand
import { GlassIconButton } from './glass-icon-button';
import { cn } from '@/public/lib/utils';
import { ReplaceVideoModal } from './replace-video-modal';
import { MediaPropertiesModal } from './media-properties-modal';
interface VideoTabContentProps {
taskSketch: any[];
@ -28,6 +29,7 @@ export function VideoTabContent({
const [progress, setProgress] = React.useState(0);
const [isReplaceModalOpen, setIsReplaceModalOpen] = React.useState(false);
const [activeReplaceMethod, setActiveReplaceMethod] = React.useState<'upload' | 'library' | 'generate'>('upload');
const [isMediaPropertiesModalOpen, setIsMediaPropertiesModalOpen] = React.useState(false);
// 监听外部播放状态变化
useEffect(() => {
@ -305,6 +307,17 @@ export function VideoTabContent({
onChange={(e) => console.log('Volume:', e.target.value)}
/>
</div>
{/* 更多设置 点击打开 Media properties 弹窗 */}
<motion.button
className='bg-[transparent] m-4 p-0 border-none rounded-lg transition-colors'
style={{textDecorationLine: 'underline'}}
onClick={() => setIsMediaPropertiesModalOpen(true)}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<div className='text-sm font-medium mb-2'>Media properties</div>
</motion.button>
</div>
{/* 右列:视频预览和操作 */}
@ -416,6 +429,15 @@ export function VideoTabContent({
}}
/>
{/* Media Properties 弹窗 */}
<MediaPropertiesModal
isOpen={isMediaPropertiesModalOpen}
onClose={() => setIsMediaPropertiesModalOpen(false)}
taskSketch={taskSketch}
currentSketchIndex={currentSketchIndex}
onSketchSelect={onSketchSelect}
/>
</div>
);

View File

@ -4,6 +4,14 @@ const nextConfig = {
ignoreDuringBuilds: true,
},
images: { unoptimized: true },
async rewrites() {
return [
{
source: '/api/proxy/:path*',
destination: 'https://77.smartvideo.py.qikongjian.com/:path*',
},
];
},
};
module.exports = nextConfig;

23
package-lock.json generated
View File

@ -47,6 +47,7 @@
"@types/react-beautiful-dnd": "^13.1.8",
"@types/react-dom": "18.2.7",
"@types/three": "^0.177.0",
"@types/wavesurfer.js": "^6.0.12",
"antd": "^5.26.2",
"autoprefixer": "10.4.15",
"axios": "^1.10.0",
@ -88,6 +89,7 @@
"three": "^0.177.0",
"typescript": "5.2.2",
"vaul": "^0.9.9",
"wavesurfer.js": "^7.9.9",
"zod": "^3.23.8"
},
"devDependencies": {
@ -6526,6 +6528,12 @@
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/debounce": {
"version": "1.2.4",
"resolved": "https://registry.npmmirror.com/@types/debounce/-/debounce-1.2.4.tgz",
"integrity": "sha512-jBqiORIzKDOToaF63Fm//haOCHuwQuLa2202RK4MozpA6lh93eCBc+/8+wZn5OzjJt3ySdc+74SXWXB55Ewtyw==",
"license": "MIT"
},
"node_modules/@types/gsap": {
"version": "1.20.2",
"resolved": "https://registry.npmmirror.com/@types/gsap/-/gsap-1.20.2.tgz",
@ -6645,6 +6653,15 @@
"meshoptimizer": "~0.18.1"
}
},
"node_modules/@types/wavesurfer.js": {
"version": "6.0.12",
"resolved": "https://registry.npmmirror.com/@types/wavesurfer.js/-/wavesurfer.js-6.0.12.tgz",
"integrity": "sha512-oM9hYlPIVms4uwwoaGs9d0qp7Xk7IjSGkdwgmhUymVUIIilRfjtSQvoOgv4dpKiW0UozWRSyXfQqTobi0qWyCw==",
"license": "MIT",
"dependencies": {
"@types/debounce": "*"
}
},
"node_modules/@types/webxr": {
"version": "0.5.22",
"resolved": "https://registry.npmmirror.com/@types/webxr/-/webxr-0.5.22.tgz",
@ -12878,6 +12895,12 @@
"node": ">=10.13.0"
}
},
"node_modules/wavesurfer.js": {
"version": "7.9.9",
"resolved": "https://registry.npmmirror.com/wavesurfer.js/-/wavesurfer.js-7.9.9.tgz",
"integrity": "sha512-8O/zu+RC7yjikxiuhsXzRZ8vvjV+Qq4PUKZBQsLLcq6fqbrSF3Vh99l7fT8zeEjKjDBNH2Qxsxq5mRJIuBmM3Q==",
"license": "BSD-3-Clause"
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@ -48,6 +48,7 @@
"@types/react-beautiful-dnd": "^13.1.8",
"@types/react-dom": "18.2.7",
"@types/three": "^0.177.0",
"@types/wavesurfer.js": "^6.0.12",
"antd": "^5.26.2",
"autoprefixer": "10.4.15",
"axios": "^1.10.0",
@ -89,6 +90,7 @@
"three": "^0.177.0",
"typescript": "5.2.2",
"vaul": "^0.9.9",
"wavesurfer.js": "^7.9.9",
"zod": "^3.23.8"
},
"devDependencies": {