forked from 77media/video-flow
完善分镜视频对应的音频设置
This commit is contained in:
parent
a5a6da472d
commit
34348881b3
@ -1 +1 @@
|
||||
export const BASE_URL = "https://movieflow.api.huiying.video"
|
||||
export const BASE_URL = "/api/proxy"
|
||||
|
||||
@ -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);
|
||||
};
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -1,7 +0,0 @@
|
||||
export function ScriptToVideo() {
|
||||
return (
|
||||
<div>
|
||||
<h1>Script To Video</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
365
components/ui/audio-visualizer.tsx
Normal file
365
components/ui/audio-visualizer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
514
components/ui/media-properties-modal.tsx
Normal file
514
components/ui/media-properties-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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
23
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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": {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user