精简项目以及修复构建问题

This commit is contained in:
北枳 2025-07-14 03:56:30 +08:00
parent 4c5be13b0f
commit bc64add617
16 changed files with 430 additions and 2466 deletions

View File

@ -1,5 +0,0 @@
import { ScriptToVideo } from '@/components/pages/script-to-video';
export default function ScriptToVideoPage() {
return <ScriptToVideo />;
}

View File

@ -1,5 +0,0 @@
import { VideoToVideo } from '@/components/pages/video-to-video';
export default function VideoToVideoPage() {
return <VideoToVideo />;
}

View File

@ -1,10 +1,11 @@
import { DashboardLayout } from '@/components/layout/dashboard-layout';
import { HistoryPage } from '@/components/pages/history-page';
export default function History() {
return (
<DashboardLayout>
<HistoryPage />
<div>
<h1>History</h1>
</div>
</DashboardLayout>
);
}

View File

@ -1,328 +0,0 @@
"use client";
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { ArrowUp } from 'lucide-react';
import gsap from 'gsap';
import { ImageWave } from '@/components/ui/ImageWave';
interface AnimationStageProps {
shouldStart: boolean;
onComplete: () => void;
}
// 动画1模拟输入和点击
const InputAnimation: React.FC<AnimationStageProps> = ({ shouldStart, onComplete }) => {
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLDivElement>(null);
const cursorRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const mouseRef = useRef<HTMLDivElement>(null);
const [displayText, setDisplayText] = useState('');
const demoText = "a cute capybara with an orange on its head";
useEffect(() => {
if (!shouldStart || !containerRef.current) return;
// 重置状态
setDisplayText('');
const tl = gsap.timeline({
onComplete: () => {
setTimeout(onComplete, 500);
}
});
// 1. 显示输入框和鼠标
tl.fromTo([inputRef.current, mouseRef.current], {
scale: 0.9,
opacity: 0
}, {
scale: 1,
opacity: 1,
duration: 0.3,
ease: "back.out(1.7)",
stagger: 0.1
});
// 2. 鼠标移动到输入框中心
tl.to(mouseRef.current, {
x: 20,
y: 0,
duration: 0.3
});
// 3. 输入框聚焦效果
tl.to(inputRef.current, {
scale: 1.02,
boxShadow: '0 0 0 2px rgba(59, 130, 246, 0.5)',
duration: 0.2
});
// 4. 打字动画
const typingDuration = demoText.length * 0.05;
tl.to({}, {
duration: typingDuration,
onUpdate: function() {
const progress = this.progress();
const targetChar = Math.floor(progress * demoText.length);
setDisplayText(demoText.slice(0, targetChar));
}
});
// 6. 鼠标移动到按钮位置(调整移动时间和缓动函数)
tl.to(mouseRef.current, {
x: 650,
y: 0,
duration: 0.8,
ease: "power2.inOut"
});
// 7. 等待一小段时间
tl.to({}, { duration: 0.2 });
// 8. 点击效果
tl.to(mouseRef.current, {
scale: 0.8,
duration: 0.1,
yoyo: true,
repeat: 1
}).to(buttonRef.current, {
scale: 0.95,
duration: 0.1,
yoyo: true,
repeat: 1
}, "<");
// 9. 等待一小段时间
tl.to({}, { duration: 0.3 });
// 10. 整体淡出
tl.to(containerRef.current, {
opacity: 0,
y: -20,
duration: 0.3
});
}, [shouldStart, onComplete]);
return (
<div ref={containerRef} className="fixed top-1/3 left-1/2 -translate-x-1/2 -translate-y-1/2">
<div className="relative flex items-center gap-4">
<div
ref={inputRef}
className="relative w-[600px] h-[50px] bg-white/10 backdrop-blur-md rounded-lg px-4 py-3 flex items-center"
>
<span className="text-white/70 text-base font-mono">
{displayText}
</span>
<div
ref={cursorRef}
className="w-0.5 h-5 bg-blue-400 ml-1 animate-pulse"
/>
</div>
<button
ref={buttonRef}
className={`${displayText ? 'bg-indigo-600 opacity-100' : 'bg-[#5b5b5b] opacity-30'} hover:bg-indigo-700 text-white px-6 py-2 rounded-md text-sm font-medium flex items-center gap-2 min-w-[100px]`}
>
<ArrowUp className="w-4 h-4" />
Action
</button>
<div
ref={mouseRef}
className="absolute left-0 top-1/2 -translate-y-1/2 opacity-0 pointer-events-none z-10"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="white" className="drop-shadow-lg">
<path d="M8 2L8 22L12 18L16 22L22 16L12 6L8 2Z" />
</svg>
</div>
</div>
</div>
);
};
// 动画2ImageWave 动画展示
const WaveAnimation: React.FC<AnimationStageProps> = ({ shouldStart, onComplete }) => {
const containerRef = useRef<HTMLDivElement>(null);
const [showWave, setShowWave] = useState(false);
const [autoAnimate, setAutoAnimate] = useState(false);
const imageUrls = [
'https://d3phaj0sisr2ct.cloudfront.net/app/gen4/object-reference/welcome-ref-1.jpg',
'https://d3phaj0sisr2ct.cloudfront.net/app/gen4/object-reference/welcome-ref-2.jpg',
'https://d3phaj0sisr2ct.cloudfront.net/app/gen4/object-reference/welcome-ref-3.jpg',
'https://d3phaj0sisr2ct.cloudfront.net/app/gen4/object-reference/welcome-ref-4.jpg',
'https://d3phaj0sisr2ct.cloudfront.net/app/gen4/object-reference/welcome-ref-1.jpg',
'https://d3phaj0sisr2ct.cloudfront.net/app/gen4/object-reference/welcome-ref-2.jpg',
'https://d3phaj0sisr2ct.cloudfront.net/app/gen4/object-reference/welcome-ref-3.jpg',
'https://d3phaj0sisr2ct.cloudfront.net/app/gen4/object-reference/welcome-ref-4.jpg',
'https://d3phaj0sisr2ct.cloudfront.net/app/gen4/object-reference/welcome-ref-3.jpg',
'https://d3phaj0sisr2ct.cloudfront.net/app/gen4/object-reference/welcome-ref-2.jpg',
'https://d3phaj0sisr2ct.cloudfront.net/app/gen4/object-reference/welcome-ref-1.jpg',
];
useEffect(() => {
if (!shouldStart) {
setShowWave(false);
setAutoAnimate(false);
return;
}
// 显示 ImageWave
setShowWave(true);
// 延迟开始自动动画
const startTimeout = setTimeout(() => {
setAutoAnimate(true);
}, 300);
return () => {
clearTimeout(startTimeout);
};
}, [shouldStart]);
const handleAnimationComplete = () => {
// 动画完成后淡出并触发完成回调
gsap.to(containerRef.current, {
opacity: 0,
scale: 0.9,
duration: 0.3,
onComplete: () => {
setAutoAnimate(false);
setShowWave(false);
onComplete();
}
});
};
return (
<div
ref={containerRef}
className={`fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 transition-all duration-300
${showWave ? 'opacity-100 scale-100' : 'opacity-0 scale-90'}`}
>
<ImageWave
images={imageUrls}
containerWidth="90vw"
containerHeight="60vh"
itemWidth="calc(var(--index) * 5)"
itemHeight="calc(var(--index) * 12)"
gap="0.3rem"
autoAnimate={autoAnimate}
autoAnimateInterval={100}
onAnimationComplete={handleAnimationComplete}
/>
</div>
);
};
// 动画3图片墙打破显示视频
const FinalAnimation: React.FC<AnimationStageProps> = ({ shouldStart, onComplete }) => {
const containerRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const [showVideo, setShowVideo] = useState(false);
const videoUrl = 'https://cdn.qikongjian.com/videos/1750385931_99a8fb42-af89-4ae9-841a-a49869f026bd_text_to_video_0.mp4';
useEffect(() => {
if (!shouldStart || !containerRef.current) return;
const tl = gsap.timeline({
onComplete: () => {
setTimeout(() => {
// 淡出视频
gsap.to(containerRef.current, {
opacity: 0,
scale: 0.9,
duration: 0.3,
onComplete
});
}, 3000);
}
});
// 显示容器
tl.fromTo(containerRef.current,
{ opacity: 0, scale: 0.9 },
{ opacity: 1, scale: 1, duration: 0.3 }
);
// 显示视频
setShowVideo(true);
}, [shouldStart, onComplete]);
return (
<div
ref={containerRef}
className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[400px] rounded-lg overflow-hidden opacity-0"
>
{showVideo && (
<video
ref={videoRef}
src={videoUrl}
className="w-full h-full object-cover"
autoPlay
muted
loop
playsInline
/>
)}
</div>
);
};
// 主组件
export const EmptyStateAnimation = ({ className }: { className: string }) => {
const [currentStage, setCurrentStage] = useState<'input' | 'wave' | 'final'>('input');
const [animationCycle, setAnimationCycle] = useState(0);
const [isReady, setIsReady] = useState(true);
const handleStageComplete = useCallback(() => {
// 先将当前阶段标记为不可执行
setIsReady(false);
// 延迟切换到下一个阶段
setTimeout(() => {
switch (currentStage) {
case 'input':
setCurrentStage('wave');
break;
case 'wave':
setCurrentStage('final');
break;
case 'final':
setAnimationCycle(prev => prev + 1);
setCurrentStage('input');
break;
}
// 给下一个阶段一些准备时间
setTimeout(() => {
setIsReady(true);
}, 100);
}, 300);
}, [currentStage]);
return (
<div className={className}>
<InputAnimation
key={`input-${animationCycle}`}
shouldStart={currentStage === 'input' && isReady}
onComplete={handleStageComplete}
/>
<WaveAnimation
key={`wave-${animationCycle}`}
shouldStart={currentStage === 'wave' && isReady}
onComplete={handleStageComplete}
/>
<FinalAnimation
key={`final-${animationCycle}`}
shouldStart={currentStage === 'final' && isReady}
onComplete={handleStageComplete}
/>
</div>
);
};

View File

@ -1,224 +0,0 @@
"use client";
import { useState, useEffect } from 'react';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import {
Search,
Filter,
Video,
Calendar,
Eye,
Edit,
Trash2,
MoreHorizontal,
Play
} from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
interface HistoryItem {
id: number;
title: string;
type: 'script-to-video' | 'video-to-video';
status: 'completed' | 'processing' | 'failed';
createdAt: string;
duration?: string;
thumbnail?: string;
}
export function HistoryPage() {
const [searchTerm, setSearchTerm] = useState('');
const [historyItems, setHistoryItems] = useState<HistoryItem[]>([]);
const [filteredItems, setFilteredItems] = useState<HistoryItem[]>([]);
// Mock data - replace with actual API call
useEffect(() => {
const mockData: HistoryItem[] = [
{
id: 1,
title: "Sample Script Video",
type: "script-to-video",
status: "completed",
createdAt: "2024-01-15",
duration: "2:30",
thumbnail: "/assets/empty_video.png"
},
{
id: 2,
title: "Video Enhancement Project",
type: "video-to-video",
status: "processing",
createdAt: "2024-01-14",
duration: "1:45"
},
{
id: 3,
title: "Marketing Video",
type: "script-to-video",
status: "completed",
createdAt: "2024-01-13",
duration: "3:15",
thumbnail: "/assets/empty_video.png"
}
];
setHistoryItems(mockData);
setFilteredItems(mockData);
}, []);
// Filter items based on search term
useEffect(() => {
const filtered = historyItems.filter(item =>
item.title.toLowerCase().includes(searchTerm.toLowerCase())
);
setFilteredItems(filtered);
}, [searchTerm, historyItems]);
const getStatusBadgeVariant = (status: string) => {
switch (status) {
case 'completed':
return 'default';
case 'processing':
return 'secondary';
case 'failed':
return 'destructive';
default:
return 'default';
}
};
const getTypeIcon = (type: string) => {
return type === 'script-to-video' ? '📝' : '🎥';
};
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h1 className="text-2xl font-bold text-white">Project History</h1>
<p className="text-white/70">View and manage your video projects</p>
</div>
{/* Search and Filter */}
<div className="flex items-center gap-2">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-white/40" />
<Input
placeholder="Search projects..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 bg-white/5 border-white/20 text-white placeholder:text-white/40"
/>
</div>
<Button variant="outline" size="sm" className="border-white/20 text-white hover:bg-white/10">
<Filter className="h-4 w-4 mr-2" />
Filter
</Button>
</div>
</div>
{/* Project Grid */}
{filteredItems.length === 0 ? (
<Card className="p-12 text-center bg-white/5 border-white/10">
<Video className="h-12 w-12 text-white/40 mx-auto mb-4" />
<h3 className="text-lg font-medium text-white/70 mb-2">
{searchTerm ? 'No projects found' : 'No projects yet'}
</h3>
<p className="text-white/50">
{searchTerm
? 'Try adjusting your search terms'
: 'Create your first video project to see it here'
}
</p>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredItems.map((item) => (
<Card key={item.id} className="bg-white/5 border-white/10 overflow-hidden hover:bg-white/10 transition-colors">
{/* Thumbnail */}
<div className="aspect-video bg-gradient-to-br from-purple-900/20 to-blue-900/20 relative">
{item.thumbnail ? (
<img
src={item.thumbnail}
alt={item.title}
className="w-full h-full object-cover"
/>
) : (
<div className="flex items-center justify-center h-full">
<Video className="h-12 w-12 text-white/40" />
</div>
)}
{/* Play overlay */}
<div className="absolute inset-0 flex items-center justify-center opacity-0 hover:opacity-100 transition-opacity bg-black/50">
<Button size="sm" className="bg-white/20 hover:bg-white/30">
<Play className="h-4 w-4" />
</Button>
</div>
{/* Type indicator */}
<div className="absolute top-2 left-2">
<span className="text-lg">{getTypeIcon(item.type)}</span>
</div>
{/* Duration */}
{item.duration && (
<div className="absolute bottom-2 right-2 bg-black/70 px-2 py-1 rounded text-xs text-white">
{item.duration}
</div>
)}
</div>
{/* Content */}
<div className="p-4">
<div className="flex items-start justify-between mb-2">
<h3 className="font-medium text-white truncate flex-1" title={item.title}>
{item.title}
</h3>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 text-white/60 hover:text-white">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>
<Eye className="h-4 w-4 mr-2" />
View
</DropdownMenuItem>
<DropdownMenuItem>
<Edit className="h-4 w-4 mr-2" />
Edit
</DropdownMenuItem>
<DropdownMenuItem className="text-red-500">
<Trash2 className="h-4 w-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-white/60">
<Calendar className="h-3 w-3" />
{item.createdAt}
</div>
<Badge variant={getStatusBadgeVariant(item.status)}>
{item.status}
</Badge>
</div>
</div>
</Card>
))}
</div>
)}
</div>
);
}

View File

@ -88,8 +88,8 @@ export function HomePage2() {
const projectData: CreateScriptProjectRequest = {
title: "script default", // 默认剧本名称
project_type: projectType,
mode: ModeEnum.MANUAL,
resolution: ResolutionEnum.FULL_HD_1080P
mode: ModeEnum.MANUAL === 'manual' ? 1 : 2, // 1 表示手动模式2 表示自动模式
resolution: 1080 // 1080p 分辨率
};
const projectResponse = await createScriptProject(projectData);
@ -121,7 +121,7 @@ export function HomePage2() {
{/* 工具栏-列表形式切换 */}
<div className="absolute top-[8rem] z-[50] right-6 w-[8rem] flex justify-end">
<LiquidButton className="w-[8rem] h-[3rem] text-sm"
onClick={(e) => {
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
handleToolChange(activeTool === "stretch" ? "right" : "left");
}}

View File

@ -1,168 +0,0 @@
"use client";
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ArrowLeft, Loader2 } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { ModeEnum, ResolutionEnum } from "@/app/model/enums";
import { createScriptEpisode, CreateScriptEpisodeRequest, updateScriptEpisode, UpdateScriptEpisodeRequest } from "@/api/script_episode";
import { convertScriptToScene } from "@/api/video_flow";
export function ScriptToVideo() {
const router = useRouter();
const [script, setScript] = useState('');
const [selectedMode, setSelectedMode] = useState<ModeEnum>(ModeEnum.AUTOMATIC);
const [selectedResolution, setSelectedResolution] = useState<ResolutionEnum>(ResolutionEnum.HD_720P);
const [isCreating, setIsCreating] = useState(false);
const handleBack = () => {
router.push('/create');
};
const handleCreate = async () => {
if (!script.trim()) {
alert('请输入剧本内容');
return;
}
try {
setIsCreating(true);
// Create episode
const episodeData: CreateScriptEpisodeRequest = {
title: "Script Episode",
script_id: 0, // This should come from a project
status: 1,
summary: script
};
const episodeResponse = await createScriptEpisode(episodeData);
if (episodeResponse.code !== 0) {
alert(`创建剧集失败: ${episodeResponse.message}`);
return;
}
const episodeId = episodeResponse.data.id;
// Convert script to scenes
const convertResponse = await convertScriptToScene(script, episodeId, 0);
if (convertResponse.code === 0) {
// Update episode with generated data
const updateEpisodeData: UpdateScriptEpisodeRequest = {
id: episodeId,
atmosphere: convertResponse.data.atmosphere,
summary: convertResponse.data.summary,
scene: convertResponse.data.scene,
characters: convertResponse.data.characters,
};
await updateScriptEpisode(updateEpisodeData);
// Navigate to workflow
router.push(`/create/work-flow?episodeId=${episodeId}`);
} else {
alert(`转换失败: ${convertResponse.message}`);
}
} catch (error) {
console.error('创建过程出错:', error);
alert("创建项目时发生错误,请稍后重试");
} finally {
setIsCreating(false);
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 p-6">
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="flex items-center gap-4 mb-8">
<Button
variant="ghost"
size="sm"
onClick={handleBack}
className="text-white/70 hover:text-white"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<h1 className="text-2xl font-bold text-white">Script to Video</h1>
</div>
{/* Main Content */}
<Card className="p-6 bg-white/5 border-white/10">
<div className="space-y-6">
{/* Script Input */}
<div>
<label className="block text-sm font-medium text-white/70 mb-2">
Script Content
</label>
<Textarea
value={script}
onChange={(e) => setScript(e.target.value)}
placeholder="Enter your script here..."
className="min-h-[200px] bg-white/5 border-white/20 text-white placeholder:text-white/50"
rows={10}
/>
</div>
{/* Settings */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-white/70 mb-2">
Mode
</label>
<Select value={selectedMode.toString()} onValueChange={(value) => setSelectedMode(Number(value) as ModeEnum)}>
<SelectTrigger className="bg-white/5 border-white/20 text-white">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ModeEnum.AUTOMATIC.toString()}>Automatic</SelectItem>
<SelectItem value={ModeEnum.MANUAL.toString()}>Manual</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label className="block text-sm font-medium text-white/70 mb-2">
Resolution
</label>
<Select value={selectedResolution.toString()} onValueChange={(value) => setSelectedResolution(Number(value) as ResolutionEnum)}>
<SelectTrigger className="bg-white/5 border-white/20 text-white">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ResolutionEnum.HD_720P.toString()}>720P HD</SelectItem>
<SelectItem value={ResolutionEnum.FULL_HD_1080P.toString()}>1080P Full HD</SelectItem>
<SelectItem value={ResolutionEnum.UHD_2K.toString()}>2K UHD</SelectItem>
<SelectItem value={ResolutionEnum.UHD_4K.toString()}>4K UHD</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Create Button */}
<div className="flex justify-end">
<Button
onClick={handleCreate}
disabled={isCreating || !script.trim()}
className="bg-purple-600 hover:bg-purple-700"
>
{isCreating ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Creating...
</>
) : (
'Create Video'
)}
</Button>
</div>
</div>
</Card>
</div>
</div>
);
}

View File

@ -1,233 +0,0 @@
"use client";
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ArrowLeft, Upload, Loader2 } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { ModeEnum, ResolutionEnum } from "@/app/model/enums";
import { createScriptEpisode, CreateScriptEpisodeRequest, updateScriptEpisode, UpdateScriptEpisodeRequest } from "@/api/script_episode";
import { convertVideoToScene } from "@/api/video_flow";
import { getUploadToken, uploadToQiniu } from "@/api/common";
export function VideoToVideo() {
const router = useRouter();
const [videoUrl, setVideoUrl] = useState('');
const [selectedMode, setSelectedMode] = useState<ModeEnum>(ModeEnum.AUTOMATIC);
const [selectedResolution, setSelectedResolution] = useState<ResolutionEnum>(ResolutionEnum.HD_720P);
const [isUploading, setIsUploading] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const handleBack = () => {
router.push('/create');
};
const handleUploadVideo = async () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'video/*';
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
try {
setIsUploading(true);
// Get upload token
const { token } = await getUploadToken();
// Upload to Qiniu
const uploadedVideoUrl = await uploadToQiniu(file, token);
setVideoUrl(uploadedVideoUrl);
} catch (error) {
console.error('Upload error:', error);
alert('Upload failed, please try again');
} finally {
setIsUploading(false);
}
}
};
input.click();
};
const handleCreate = async () => {
if (!videoUrl) {
alert('请先上传视频');
return;
}
try {
setIsCreating(true);
// Create episode
const episodeData: CreateScriptEpisodeRequest = {
title: "Video Episode",
script_id: 0, // This should come from a project
status: 1,
summary: "Video conversion"
};
const episodeResponse = await createScriptEpisode(episodeData);
if (episodeResponse.code !== 0) {
alert(`创建剧集失败: ${episodeResponse.message}`);
return;
}
const episodeId = episodeResponse.data.id;
// Convert video to scenes
const convertResponse = await convertVideoToScene(videoUrl, episodeId, 0);
if (convertResponse.code === 0) {
// Update episode with generated data
const updateEpisodeData: UpdateScriptEpisodeRequest = {
id: episodeId,
atmosphere: convertResponse.data.atmosphere,
summary: convertResponse.data.summary,
scene: convertResponse.data.scene,
characters: convertResponse.data.characters,
};
await updateScriptEpisode(updateEpisodeData);
// Navigate to workflow
router.push(`/create/work-flow?episodeId=${episodeId}`);
} else {
alert(`转换失败: ${convertResponse.message}`);
}
} catch (error) {
console.error('创建过程出错:', error);
alert("创建项目时发生错误,请稍后重试");
} finally {
setIsCreating(false);
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 p-6">
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="flex items-center gap-4 mb-8">
<Button
variant="ghost"
size="sm"
onClick={handleBack}
className="text-white/70 hover:text-white"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<h1 className="text-2xl font-bold text-white">Video to Video</h1>
</div>
{/* Main Content */}
<Card className="p-6 bg-white/5 border-white/10">
<div className="space-y-6">
{/* Video Upload */}
<div>
<label className="block text-sm font-medium text-white/70 mb-2">
Upload Video
</label>
<div className="border-2 border-dashed border-white/20 rounded-lg p-8 text-center">
{videoUrl ? (
<div className="space-y-4">
<video
src={videoUrl}
controls
className="max-w-full h-64 mx-auto rounded-lg"
/>
<Button
variant="outline"
onClick={handleUploadVideo}
disabled={isUploading}
className="border-white/20 text-white hover:bg-white/10"
>
Replace Video
</Button>
</div>
) : (
<div className="space-y-4">
<Upload className="h-12 w-12 text-white/40 mx-auto" />
<div>
<p className="text-white/70 mb-2">Click to upload a video file</p>
<Button
onClick={handleUploadVideo}
disabled={isUploading}
className="bg-purple-600 hover:bg-purple-700"
>
{isUploading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Uploading...
</>
) : (
<>
<Upload className="h-4 w-4 mr-2" />
Choose Video
</>
)}
</Button>
</div>
</div>
)}
</div>
</div>
{/* Settings */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-white/70 mb-2">
Mode
</label>
<Select value={selectedMode.toString()} onValueChange={(value) => setSelectedMode(Number(value) as ModeEnum)}>
<SelectTrigger className="bg-white/5 border-white/20 text-white">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ModeEnum.AUTOMATIC.toString()}>Automatic</SelectItem>
<SelectItem value={ModeEnum.MANUAL.toString()}>Manual</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label className="block text-sm font-medium text-white/70 mb-2">
Resolution
</label>
<Select value={selectedResolution.toString()} onValueChange={(value) => setSelectedResolution(Number(value) as ResolutionEnum)}>
<SelectTrigger className="bg-white/5 border-white/20 text-white">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ResolutionEnum.HD_720P.toString()}>720P HD</SelectItem>
<SelectItem value={ResolutionEnum.FULL_HD_1080P.toString()}>1080P Full HD</SelectItem>
<SelectItem value={ResolutionEnum.UHD_2K.toString()}>2K UHD</SelectItem>
<SelectItem value={ResolutionEnum.UHD_4K.toString()}>4K UHD</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Create Button */}
<div className="flex justify-end">
<Button
onClick={handleCreate}
disabled={isCreating || !videoUrl}
className="bg-purple-600 hover:bg-purple-700"
>
{isCreating ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Creating...
</>
) : (
'Create Video'
)}
</Button>
</div>
</div>
</Card>
</div>
</div>
);
}

View File

@ -8,7 +8,7 @@ import { ErrorBoundary } from "@/components/ui/error-boundary";
import { TaskInfo } from "./work-flow/task-info";
import { MediaViewer } from "./work-flow/media-viewer";
import { ThumbnailGrid } from "./work-flow/thumbnail-grid";
import { useWorkflowData } from "./work-flow/use-workflow-data.ts";
import { useWorkflowData } from "./work-flow/use-workflow-data";
import { usePlaybackControls } from "./work-flow/use-playback-controls";
import { AlertCircle, RefreshCw } from "lucide-react";
import { motion } from "framer-motion";

View File

@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import { detailScriptEpisodeNew, getScriptTitle, getRunningStreamData, StreamData } from '@/api/video_flow';
import { useSearchParams } from 'next/navigation';
import { ApiResponse } from '@/api/common';
// 步骤映射
const STEP_MAP = {
@ -56,8 +57,8 @@ export const useApiData = () => {
const [streamInterval, setStreamInterval] = useState<NodeJS.Timeout | null>(null);
// 处理流式数据
const handleStreamData = useCallback((streamData: StreamData) => {
const { category, message, data, status } = streamData;
const handleStreamData = useCallback((streamData: ApiResponse<StreamData>) => {
const { category, message, data, status } = streamData.data;
// 更新加载文本
setCurrentLoadingText(message);
@ -114,7 +115,7 @@ export const useApiData = () => {
});
// 如果状态为 completed停止获取流式数据
if (status === 'completed' || streamData.all_completed) {
if (status === 'completed' || streamData.data.all_completed) {
setNeedStreamData(false);
}
}, []);
@ -124,7 +125,7 @@ export const useApiData = () => {
if (!episodeId || !needStreamData) return;
try {
const streamData = await getRunningStreamData({ episodeId });
const streamData = await getRunningStreamData({ project_id: episodeId });
handleStreamData(streamData);
} catch (error) {
console.error('获取流式数据失败:', error);

View File

@ -1,406 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { useSearchParams } from 'next/navigation';
import { detailScriptEpisodeNew, getScriptTitle, getRunningStreamData } from '@/api/video_flow';
// 步骤映射
const STEP_MAP = {
'initializing': '0',
'sketch': '1',
'character': '2',
'video': '3',
'music': '4',
'final_video': '6'
} as const;
// 执行loading文字映射
const LOADING_TEXT_MAP = {
initializing: 'initializing...',
sketch: (count: number, total: number) => `Generating sketch ${count + 1 > total ? total : count + 1}/${total}...`,
sketchComplete: 'Sketch generation complete',
character: 'Drawing characters...',
newCharacter: (count: number, total: number) => `Drawing character ${count + 1 > total ? total : count + 1}/${total}...`,
video: (count: number, total: number) => `Generating video ${count + 1 > total ? total : count + 1}/${total}...`,
videoComplete: 'Video generation complete',
audio: 'Generating background audio...',
postProduction: (step: string) => `Post-production: ${step}...`,
final: 'Generating final product...',
complete: 'Task completed'
} as const;
type ApiStep = keyof typeof STEP_MAP;
// 添加 TaskObject 接口
interface TaskObject {
taskStatus: string;
title: string;
currentLoadingText: string;
sketchCount?: number;
totalSketchCount?: number;
isGeneratingSketch?: boolean;
isGeneratingVideo?: boolean;
roles?: any[];
music?: any[];
final?: any;
}
export function useWorkflowData() {
const searchParams = useSearchParams();
const episodeId = searchParams.get('episodeId');
// 更新 taskObject 的类型
const [taskObject, setTaskObject] = useState<TaskObject | null>(null);
const [taskSketch, setTaskSketch] = useState<any[]>([]);
const [taskVideos, setTaskVideos] = useState<any[]>([]);
const [sketchCount, setSketchCount] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [currentStep, setCurrentStep] = useState('0');
const [currentSketchIndex, setCurrentSketchIndex] = useState(0);
const [isGeneratingSketch, setIsGeneratingSketch] = useState(false);
const [isGeneratingVideo, setIsGeneratingVideo] = useState(false);
const [currentLoadingText, setCurrentLoadingText] = useState('正在加载项目数据...');
const [totalSketchCount, setTotalSketchCount] = useState(0);
const [roles, setRoles] = useState<any[]>([]);
const [music, setMusic] = useState<any[]>([]);
const [final, setFinal] = useState<any>(null);
const [dataLoadError, setDataLoadError] = useState<string | null>(null);
const [needStreamData, setNeedStreamData] = useState(false);
// 获取流式数据
const fetchStreamData = async () => {
if (!episodeId || !needStreamData) return;
try {
const response = await getRunningStreamData({ project_id: episodeId });
if (!response.successful) {
throw new Error(response.message);
}
let loadingText: any = LOADING_TEXT_MAP.initializing;
let finalStep = '1', sketchCount = 0;
const all_task_data = response.data;
// all_task_data 下标0 和 下标1 换位置
const temp = all_task_data[0];
all_task_data[0] = all_task_data[1];
all_task_data[1] = temp;
console.log('all_task_data', all_task_data);
for (const task of all_task_data) {
// 如果有已完成的数据,同步到状态
if (task.task_name === 'generate_sketch' && task.task_result) {
if (task.task_result.data.length >= 0 && taskSketch.length !== task.task_result.data.length) {
// 正在生成草图中 替换 sketch 数据
const sketchList = [];
for (const sketch of task.task_result.data) {
sketchList.push({
url: sketch.image_path,
script: sketch.sketch_name
});
}
setTaskSketch(sketchList);
setSketchCount(sketchList.length);
setIsGeneratingSketch(true);
setCurrentSketchIndex(sketchList.length - 1);
loadingText = LOADING_TEXT_MAP.sketch(sketchList.length, task.task_result.total_count);
}
if (task.task_status === 'COMPLETED') {
// 草图生成完成
setIsGeneratingSketch(false);
sketchCount = task.task_result.total_count;
console.log('----------草图生成完成', sketchCount);
loadingText = LOADING_TEXT_MAP.sketchComplete;
finalStep = '2';
}
setTotalSketchCount(task.task_result.total_count);
}
if (task.task_name === 'generate_character' && task.task_result) {
if (task.task_result.data.length >= 0 && roles.length !== task.task_result.data.length) {
// 正在生成角色中 替换角色数据
const characterList = [];
for (const character of task.task_result.data) {
characterList.push({
name: character.character_name,
url: character.image_path,
sound: null,
soundDescription: '',
roleDescription: ''
});
}
setRoles(characterList);
loadingText = LOADING_TEXT_MAP.newCharacter(characterList.length, task.task_result.total_count);
}
if (task.task_status === 'COMPLETED') {
console.log('----------角色生成完成,有几个分镜', sketchCount);
// 角色生成完成
finalStep = '3';
loadingText = LOADING_TEXT_MAP.video(0, sketchCount);
}
}
if (task.task_name === 'generate_videos' && task.task_result) {
if (task.task_result.data.length >= 0 && taskVideos.length !== task.task_result.data.length) {
// 正在生成视频中 替换视频数据
const videoList = [];
for (const video of task.task_result.data) {
// 每一项 video 有多个视频 先默认取第一个
videoList.push({
url: video[0].qiniuVideoUrl,
script: video[0].operation.metadata.video.prompt,
audio: null,
});
}
setTaskVideos(videoList);
setIsGeneratingVideo(true);
setCurrentSketchIndex(videoList.length - 1);
loadingText = LOADING_TEXT_MAP.video(videoList.length, task.task_result.total_count);
}
if (task.task_status === 'COMPLETED') {
console.log('----------视频生成完成');
// 视频生成完成
setIsGeneratingVideo(false);
finalStep = '4';
// 暂时没有音频生成 直接跳过
finalStep = '5';
loadingText = LOADING_TEXT_MAP.postProduction('post-production...');
}
}
if (task.task_name === 'generate_final_video') {
if (task.task_result && task.task_result.video) {
setFinal({
url: task.task_result.video,
})
finalStep = '6';
loadingText = LOADING_TEXT_MAP.complete;
// 停止轮询
setNeedStreamData(false);
}
}
}
console.log('----------finalStep', finalStep);
// 设置步骤
setCurrentStep(finalStep);
setTaskObject(prev => {
if (!prev) return null;
return {
...prev,
taskStatus: finalStep
};
});
setCurrentLoadingText(loadingText);
} catch (error) {
console.error('获取数据失败:', error);
}
};
// 轮询获取流式数据
useEffect(() => {
let interval: NodeJS.Timeout;
if (needStreamData) {
interval = setInterval(fetchStreamData, 10000);
fetchStreamData(); // 立即执行一次
}
return () => {
if (interval) {
clearInterval(interval);
}
};
}, [needStreamData]);
// 初始化数据
const initializeWorkflow = async () => {
if (!episodeId) {
setDataLoadError('缺少必要的参数');
setIsLoading(false);
return;
}
try {
setIsLoading(true);
setCurrentLoadingText('正在加载项目数据...');
// 获取剧集详情
const response = await detailScriptEpisodeNew({ project_id: episodeId });
if (!response.successful) {
throw new Error(response.message);
}
const { name, status, data } = response.data;
setIsLoading(false);
// 设置初始数据
setTaskObject({
taskStatus: '0',
title: name || 'generating...',
currentLoadingText: status === 'COMPLETED' ? LOADING_TEXT_MAP.complete : LOADING_TEXT_MAP.initializing
});
// 设置标题
if (!name) {
// 如果没有标题,轮询获取
const titleResponse = await getScriptTitle({ project_id: episodeId });
console.log('titleResponse', titleResponse);
if (titleResponse.successful) {
setTaskObject((prev: TaskObject | null) => ({
...(prev || {}),
title: titleResponse.data.name
} as TaskObject));
}
}
let loadingText: any = LOADING_TEXT_MAP.initializing;
if (status === 'COMPLETED') {
loadingText = LOADING_TEXT_MAP.complete;
}
// 如果有已完成的数据,同步到状态
let finalStep = '1';
if (data) {
if (data.sketch && data.sketch.data && data.sketch.data.length > 0) {
const sketchList = [];
for (const sketch of data.sketch.data) {
sketchList.push({
url: sketch.image_path,
script: sketch.sketch_name,
});
}
setTaskSketch(sketchList);
setSketchCount(sketchList.length);
setTotalSketchCount(data.sketch.total_count);
// 设置为最后一个草图
if (data.sketch.total_count > data.sketch.data.length) {
setIsGeneratingSketch(true);
setCurrentSketchIndex(data.sketch.data.length - 1);
loadingText = LOADING_TEXT_MAP.sketch(data.sketch.data.length, data.sketch.total_count);
} else {
finalStep = '2';
if (!data.character || !data.character.data || !data.character.data.length) {
loadingText = LOADING_TEXT_MAP.newCharacter(0, data.character.total_count);
}
}
}
if (data.character && data.character.data && data.character.data.length > 0) {
const characterList = [];
for (const character of data.character.data) {
characterList.push({
name: character.character_name,
url: character.image_path,
sound: null,
soundDescription: '',
roleDescription: ''
});
}
setRoles(characterList);
if (data.character.total_count > data.character.data.length) {
loadingText = LOADING_TEXT_MAP.newCharacter(data.character.data.length, data.character.total_count);
} else {
finalStep = '3';
if (!data.video || !data.video.data || !data.video.data.length) {
loadingText = LOADING_TEXT_MAP.video(0, data.video.total_count || data.sketch.total_count);
}
}
}
if (data.video && data.video.data && data.video.data.length > 0) {
const videoList = [];
for (const video of data.video.data) {
// 每一项 video 有多个视频 先默认取第一个
videoList.push({
url: video[0].qiniuVideoUrl,
script: video[0].operation.metadata.video.prompt,
audio: null,
});
}
setTaskVideos(videoList);
// 如果在视频步骤,设置为最后一个视频
if (data.video.total_count > data.video.data.length) {
setIsGeneratingVideo(true);
setCurrentSketchIndex(data.video.data.length - 1);
loadingText = LOADING_TEXT_MAP.video(data.video.data.length, data.video.total_count);
} else {
finalStep = '4';
loadingText = LOADING_TEXT_MAP.audio;
// 暂时没有音频生成 直接跳过
finalStep = '5';
loadingText = LOADING_TEXT_MAP.postProduction('post-production...');
}
}
if (data.final_video && data.final_video.video) {
setFinal({
url: data.final_video.video
});
finalStep = '6';
loadingText = LOADING_TEXT_MAP.complete;
}
}
// 设置步骤
setCurrentStep(finalStep);
setTaskObject(prev => {
if (!prev) return null;
return {
...prev,
taskStatus: finalStep
};
});
console.log('---------loadingText', loadingText);
setCurrentLoadingText(loadingText);
// 设置是否需要获取流式数据
setNeedStreamData(status !== 'COMPLETED' && finalStep !== '6');
} catch (error) {
console.error('初始化失败:', error);
setDataLoadError('加载失败,请重试');
setIsLoading(false);
}
};
// 重试加载数据
const retryLoadData = () => {
setDataLoadError(null);
// 重置所有状态
setTaskSketch([]);
setTaskVideos([]);
setSketchCount(0);
setTotalSketchCount(0);
setRoles([]);
setMusic([]);
setFinal(null);
setCurrentSketchIndex(0);
setCurrentStep('0');
// 重新初始化
initializeWorkflow();
};
// 初始化
useEffect(() => {
initializeWorkflow();
}, [episodeId]);
return {
taskObject,
taskSketch,
taskVideos,
sketchCount,
isLoading,
currentStep,
currentSketchIndex,
isGeneratingSketch,
isGeneratingVideo,
currentLoadingText,
totalSketchCount,
roles,
music,
final,
dataLoadError,
setCurrentSketchIndex,
retryLoadData,
};
}

View File

@ -1,15 +1,56 @@
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { getRandomMockData, STEP_MESSAGES, MOCK_DELAY_TIME, MOCK_DATA } from '@/components/work-flow/constants';
import { useState, useEffect } from 'react';
import { useSearchParams } from 'next/navigation';
import { detailScriptEpisodeNew, getScriptTitle, getRunningStreamData } from '@/api/video_flow';
// 当前选择的mock数据
let selectedMockData: any = null;
// 步骤映射
const STEP_MAP = {
'initializing': '0',
'sketch': '1',
'character': '2',
'video': '3',
'music': '4',
'final_video': '6'
} as const;
// 执行loading文字映射
const LOADING_TEXT_MAP = {
initializing: 'initializing...',
sketch: (count: number, total: number) => `Generating sketch ${count + 1 > total ? total : count + 1}/${total}...`,
sketchComplete: 'Sketch generation complete',
character: 'Drawing characters...',
newCharacter: (count: number, total: number) => `Drawing character ${count + 1 > total ? total : count + 1}/${total}...`,
video: (count: number, total: number) => `Generating video ${count + 1 > total ? total : count + 1}/${total}...`,
videoComplete: 'Video generation complete',
audio: 'Generating background audio...',
postProduction: (step: string) => `Post-production: ${step}...`,
final: 'Generating final product...',
complete: 'Task completed'
} as const;
type ApiStep = keyof typeof STEP_MAP;
// 添加 TaskObject 接口
interface TaskObject {
taskStatus: string;
title: string;
currentLoadingText: string;
sketchCount?: number;
totalSketchCount?: number;
isGeneratingSketch?: boolean;
isGeneratingVideo?: boolean;
roles?: any[];
music?: any[];
final?: any;
}
export function useWorkflowData() {
const [taskObject, setTaskObject] = useState<any>(null);
const searchParams = useSearchParams();
const episodeId = searchParams.get('episodeId');
// 更新 taskObject 的类型
const [taskObject, setTaskObject] = useState<TaskObject | null>(null);
const [taskSketch, setTaskSketch] = useState<any[]>([]);
const [taskRoles, setTaskRoles] = useState<any[]>([]);
const [taskVideos, setTaskVideos] = useState<any[]>([]);
const [sketchCount, setSketchCount] = useState(0);
const [isLoading, setIsLoading] = useState(true);
@ -18,321 +59,347 @@ export function useWorkflowData() {
const [isGeneratingSketch, setIsGeneratingSketch] = useState(false);
const [isGeneratingVideo, setIsGeneratingVideo] = useState(false);
const [currentLoadingText, setCurrentLoadingText] = useState('正在加载项目数据...');
const [totalSketchCount, setTotalSketchCount] = useState(0);
const [roles, setRoles] = useState<any[]>([]);
const [music, setMusic] = useState<any[]>([]);
const [final, setFinal] = useState<any>(null);
const [dataLoadError, setDataLoadError] = useState<string | null>(null);
const [isLoadingData, setIsLoadingData] = useState(false);
const [needStreamData, setNeedStreamData] = useState(false);
// 获取流式数据
const fetchStreamData = async () => {
if (!episodeId || !needStreamData) return;
// 异步加载数据 - 改进错误处理和fallback机制
const loadMockData = async () => {
try {
setIsLoadingData(true);
setDataLoadError(null);
setCurrentLoadingText('正在从服务器获取项目数据...');
const response = await getRunningStreamData({ project_id: episodeId });
if (!response.successful) {
throw new Error(response.message);
}
// 尝试从接口获取数据
selectedMockData = await getRandomMockData();
let loadingText: any = LOADING_TEXT_MAP.initializing;
let finalStep = '1', sketchCount = 0;
const all_task_data = response.data;
// all_task_data 下标0 和 下标1 换位置
const temp = all_task_data[0];
all_task_data[0] = all_task_data[1];
all_task_data[1] = temp;
console.log('成功从接口获取数据:', selectedMockData);
setCurrentLoadingText('项目数据加载完成');
console.log('all_task_data', all_task_data);
for (const task of all_task_data) {
// 如果有已完成的数据,同步到状态
if (task.task_name === 'generate_sketch' && task.task_result) {
if (task.task_result.data.length >= 0 && taskSketch.length !== task.task_result.data.length) {
// 正在生成草图中 替换 sketch 数据
const sketchList = [];
for (const sketch of task.task_result.data) {
sketchList.push({
url: sketch.image_path,
script: sketch.sketch_name
});
}
setTaskSketch(sketchList);
setSketchCount(sketchList.length);
setIsGeneratingSketch(true);
setCurrentSketchIndex(sketchList.length - 1);
loadingText = LOADING_TEXT_MAP.sketch(sketchList.length, task.task_result.total_count);
}
if (task.task_status === 'COMPLETED') {
// 草图生成完成
setIsGeneratingSketch(false);
sketchCount = task.task_result.total_count;
console.log('----------草图生成完成', sketchCount);
loadingText = LOADING_TEXT_MAP.sketchComplete;
finalStep = '2';
}
setTotalSketchCount(task.task_result.total_count);
}
if (task.task_name === 'generate_character' && task.task_result) {
if (task.task_result.data.length >= 0 && roles.length !== task.task_result.data.length) {
// 正在生成角色中 替换角色数据
const characterList = [];
for (const character of task.task_result.data) {
characterList.push({
name: character.character_name,
url: character.image_path,
sound: null,
soundDescription: '',
roleDescription: ''
});
}
setRoles(characterList);
loadingText = LOADING_TEXT_MAP.newCharacter(characterList.length, task.task_result.total_count);
}
if (task.task_status === 'COMPLETED') {
console.log('----------角色生成完成,有几个分镜', sketchCount);
// 角色生成完成
finalStep = '3';
loadingText = LOADING_TEXT_MAP.video(0, sketchCount);
}
}
if (task.task_name === 'generate_videos' && task.task_result) {
if (task.task_result.data.length >= 0 && taskVideos.length !== task.task_result.data.length) {
// 正在生成视频中 替换视频数据
const videoList = [];
for (const video of task.task_result.data) {
// 每一项 video 有多个视频 先默认取第一个
videoList.push({
url: video[0].qiniuVideoUrl,
script: video[0].operation.metadata.video.prompt,
audio: null,
});
}
setTaskVideos(videoList);
setIsGeneratingVideo(true);
setCurrentSketchIndex(videoList.length - 1);
loadingText = LOADING_TEXT_MAP.video(videoList.length, task.task_result.total_count);
}
if (task.task_status === 'COMPLETED') {
console.log('----------视频生成完成');
// 视频生成完成
setIsGeneratingVideo(false);
finalStep = '4';
// 暂时没有音频生成 直接跳过
finalStep = '5';
loadingText = LOADING_TEXT_MAP.postProduction('post-production...');
}
}
if (task.task_name === 'generate_final_video') {
if (task.task_result && task.task_result.video) {
setFinal({
url: task.task_result.video,
})
finalStep = '6';
loadingText = LOADING_TEXT_MAP.complete;
// 停止轮询
setNeedStreamData(false);
}
}
}
console.log('----------finalStep', finalStep);
// 设置步骤
setCurrentStep(finalStep);
setTaskObject(prev => {
if (!prev) return null;
return {
...prev,
taskStatus: finalStep
};
});
setCurrentLoadingText(loadingText);
} catch (error) {
// 报错
} finally {
setIsLoadingData(false);
console.error('获取数据失败:', error);
}
};
// 轮询获取流式数据
useEffect(() => {
let interval: NodeJS.Timeout;
if (needStreamData) {
interval = setInterval(fetchStreamData, 10000);
fetchStreamData(); // 立即执行一次
}
return () => {
if (interval) {
clearInterval(interval);
}
};
}, [needStreamData]);
// 初始化数据
const initializeWorkflow = async () => {
if (!episodeId) {
setDataLoadError('缺少必要的参数');
setIsLoading(false);
return;
}
try {
setIsLoading(true);
setCurrentLoadingText('正在加载项目数据...');
// 获取剧集详情
const response = await detailScriptEpisodeNew({ project_id: episodeId });
if (!response.successful) {
throw new Error(response.message);
}
const { name, status, data } = response.data;
setIsLoading(false);
// 设置初始数据
setTaskObject({
taskStatus: '0',
title: name || 'generating...',
currentLoadingText: status === 'COMPLETED' ? LOADING_TEXT_MAP.complete : LOADING_TEXT_MAP.initializing
});
// 设置标题
if (!name) {
// 如果没有标题,轮询获取
const titleResponse = await getScriptTitle({ project_id: episodeId });
console.log('titleResponse', titleResponse);
if (titleResponse.successful) {
setTaskObject((prev: TaskObject | null) => ({
...(prev || {}),
title: titleResponse.data.name
} as TaskObject));
}
}
let loadingText: any = LOADING_TEXT_MAP.initializing;
if (status === 'COMPLETED') {
loadingText = LOADING_TEXT_MAP.complete;
}
// 如果有已完成的数据,同步到状态
let finalStep = '1';
if (data) {
if (data.sketch && data.sketch.data && data.sketch.data.length > 0) {
const sketchList = [];
for (const sketch of data.sketch.data) {
sketchList.push({
url: sketch.image_path,
script: sketch.sketch_name,
});
}
setTaskSketch(sketchList);
setSketchCount(sketchList.length);
setTotalSketchCount(data.sketch.total_count);
// 设置为最后一个草图
if (data.sketch.total_count > data.sketch.data.length) {
setIsGeneratingSketch(true);
setCurrentSketchIndex(data.sketch.data.length - 1);
loadingText = LOADING_TEXT_MAP.sketch(data.sketch.data.length, data.sketch.total_count);
} else {
finalStep = '2';
if (!data.character || !data.character.data || !data.character.data.length) {
loadingText = LOADING_TEXT_MAP.newCharacter(0, data.character.total_count);
}
}
}
if (data.character && data.character.data && data.character.data.length > 0) {
const characterList = [];
for (const character of data.character.data) {
characterList.push({
name: character.character_name,
url: character.image_path,
sound: null,
soundDescription: '',
roleDescription: ''
});
}
setRoles(characterList);
if (data.character.total_count > data.character.data.length) {
loadingText = LOADING_TEXT_MAP.newCharacter(data.character.data.length, data.character.total_count);
} else {
finalStep = '3';
if (!data.video || !data.video.data || !data.video.data.length) {
loadingText = LOADING_TEXT_MAP.video(0, data.video.total_count || data.sketch.total_count);
}
}
}
if (data.video && data.video.data && data.video.data.length > 0) {
const videoList = [];
for (const video of data.video.data) {
// 每一项 video 有多个视频 先默认取第一个
videoList.push({
url: video[0].qiniuVideoUrl,
script: video[0].operation.metadata.video.prompt,
audio: null,
});
}
setTaskVideos(videoList);
// 如果在视频步骤,设置为最后一个视频
if (data.video.total_count > data.video.data.length) {
setIsGeneratingVideo(true);
setCurrentSketchIndex(data.video.data.length - 1);
loadingText = LOADING_TEXT_MAP.video(data.video.data.length, data.video.total_count);
} else {
finalStep = '4';
loadingText = LOADING_TEXT_MAP.audio;
// 暂时没有音频生成 直接跳过
finalStep = '5';
loadingText = LOADING_TEXT_MAP.postProduction('post-production...');
}
}
if (data.final_video && data.final_video.video) {
setFinal({
url: data.final_video.video
});
finalStep = '6';
loadingText = LOADING_TEXT_MAP.complete;
}
}
// 设置步骤
setCurrentStep(finalStep);
setTaskObject(prev => {
if (!prev) return null;
return {
...prev,
taskStatus: finalStep
};
});
console.log('---------loadingText', loadingText);
setCurrentLoadingText(loadingText);
// 设置是否需要获取流式数据
setNeedStreamData(status !== 'COMPLETED' && finalStep !== '6');
} catch (error) {
console.error('初始化失败:', error);
setDataLoadError('加载失败,请重试');
setIsLoading(false);
}
};
// 重试加载数据
const retryLoadData = async () => {
console.log('用户点击重试,重新加载数据...');
selectedMockData = null; // 重置数据
const retryLoadData = () => {
setDataLoadError(null);
setIsLoading(true);
setCurrentStep('0');
// 重新初始化整个流程
await initializeWorkflow();
};
// 模拟接口请求 获取任务详情
const getTaskDetail = async (taskId: string) => {
// 确保已经加载了数据
if (!selectedMockData) {
console.warn('selectedMockData为空重新加载数据');
await loadMockData();
}
// 确保数据结构正确
if (!selectedMockData || !selectedMockData.detail) {
throw new Error('数据结构不正确');
}
const data = {
projectId: selectedMockData.detail.projectId,
projectName: selectedMockData.detail.projectName,
taskId: taskId,
taskName: selectedMockData.detail.taskName,
taskDescription: selectedMockData.detail.taskDescription,
taskStatus: selectedMockData.detail.taskStatus,
taskProgress: 0,
mode: selectedMockData.detail.mode,
resolution: selectedMockData.detail.resolution.toString(),
};
return data;
};
// 模拟接口请求 每次获取一个分镜草图 轮询获取
const getTaskSketch = async (taskId: string) => {
if (isGeneratingSketch || taskSketch.length > 0) return;
setIsGeneratingSketch(true);
// 重置所有状态
setTaskSketch([]);
const sketchData = selectedMockData.sketch;
const totalSketches = sketchData.length;
// 模拟分批获取分镜草图
for (let i = 0; i < totalSketches; i++) {
await new Promise(resolve => setTimeout(resolve, MOCK_DELAY_TIME.sketch)); // 10s
const newSketch = {
id: `sketch-${i}`,
url: sketchData[i].url,
script: sketchData[i].script,
bg_rgb: sketchData[i].bg_rgb,
status: 'done'
};
setTaskSketch(prev => {
if (prev.find(sketch => sketch.id === newSketch.id)) {
return prev;
}
return [...prev, newSketch];
});
setCurrentSketchIndex(i);
setSketchCount(i + 1);
}
// 等待最后一个动画完成再设置生成状态为false
await new Promise(resolve => setTimeout(resolve, 1500));
setIsGeneratingSketch(false);
};
// 模拟接口请求 每次获取一个角色 轮询获取
const getTaskRole = async (taskId: string) => {
setTaskRoles([]);
const roleData = selectedMockData.roles;
const totalRoles = roleData.length;
for (let i = 0; i < totalRoles; i++) {
// 先更新loading文字显示当前正在生成的角色
setCurrentLoadingText(STEP_MESSAGES.newCharacter(i, totalRoles));
await new Promise(resolve => setTimeout(resolve, MOCK_DELAY_TIME.character)); // 2s 一个角色
// 添加角色到列表
setTaskRoles(prev => [...prev, roleData[i]]);
// 更新loading文字显示已完成的角色数量
setCurrentLoadingText(STEP_MESSAGES.newCharacter(i + 1, totalRoles));
// 如果不是最后一个角色,稍微延迟一下让用户看到更新
if (i < totalRoles - 1) {
await new Promise(resolve => setTimeout(resolve, 500));
}
}
};
// 模拟接口请求 获取背景音
const getTaskBackgroundAudio = async (taskId: string) => {
await new Promise(resolve => setTimeout(resolve, MOCK_DELAY_TIME.audio)); // 10s
};
// 模拟接口请求 获取最终成品
const getTaskFinalProduct = async (taskId: string) => {
await new Promise(resolve => setTimeout(resolve, MOCK_DELAY_TIME.final)); // 50s
};
// 模拟接口请求 每次获取一个分镜视频 轮询获取
const getTaskVideo = async (taskId: string) => {
setIsGeneratingVideo(true);
setTaskVideos([]);
const videoData = selectedMockData.video;
const totalVideos = videoData.length;
// 模拟分批获取分镜视频
for (let i = 0; i < totalVideos; i++) {
await new Promise(resolve => setTimeout(resolve, MOCK_DELAY_TIME.video)); // 60s
const newVideo = {
id: `video-${i}`,
url: videoData[i].url,
script: videoData[i].script,
status: 'done'
setSketchCount(0);
setTotalSketchCount(0);
setRoles([]);
setMusic([]);
setFinal(null);
setCurrentSketchIndex(0);
setCurrentStep('0');
// 重新初始化
initializeWorkflow();
};
setTaskVideos(prev => {
if (prev.find(video => video.id === newVideo.id)) {
return prev;
}
return [...prev, newVideo];
});
setCurrentSketchIndex(i);
}
// 等待最后一个动画完成再设置生成状态为false
await new Promise(resolve => setTimeout(resolve, 1500));
setIsGeneratingVideo(false);
};
// 更新加载文字
useEffect(() => {
if (isLoading) {
// 在初始加载阶段保持当前loading文字不变
return;
}
const totalSketches = selectedMockData?.sketch?.length || 0;
const totalVideos = selectedMockData?.video?.length || 0;
const totalCharacters = selectedMockData?.roles?.length || 0;
if (currentStep === '1') {
if (isGeneratingSketch) {
setCurrentLoadingText(STEP_MESSAGES.sketch(sketchCount, totalSketches));
} else {
setCurrentLoadingText(STEP_MESSAGES.sketchComplete);
}
} else if (currentStep === '2') {
// 在角色生成阶段loading文字已经在 getTaskRole 函数中直接管理
// 这里不需要额外设置,避免覆盖
if (taskRoles.length === totalCharacters) {
setCurrentLoadingText(STEP_MESSAGES.newCharacter(totalCharacters, totalCharacters));
}
} else if (currentStep === '3') {
if (isGeneratingVideo) {
setCurrentLoadingText(STEP_MESSAGES.video(taskVideos.length, totalVideos));
} else {
setCurrentLoadingText(STEP_MESSAGES.videoComplete);
}
} else if (currentStep === '4') {
setCurrentLoadingText(STEP_MESSAGES.audio);
} else if (currentStep === '5') {
setCurrentLoadingText(STEP_MESSAGES.final);
} else {
setCurrentLoadingText(STEP_MESSAGES.complete);
}
}, [isLoading, currentStep, isGeneratingSketch, sketchCount, isGeneratingVideo, taskVideos.length, taskSketch.length, taskRoles.length]);
// 工作流初始化函数
const initializeWorkflow = async () => {
try {
setIsLoading(true);
setCurrentLoadingText('正在初始化工作流...');
const taskId = (typeof window !== 'undefined' ? localStorage.getItem("taskId") : null) || "taskId-123";
// 首先加载数据
await loadMockData();
// 然后获取任务详情
setCurrentLoadingText('正在加载任务详情...');
const data = await getTaskDetail(taskId);
setTaskObject(data);
// 数据加载完成,进入工作流
setIsLoading(false);
setCurrentStep('1');
// 只在任务详情加载完成后获取分镜草图
await getTaskSketch(taskId);
// 修改 taskObject 下的 taskStatus 为 '2'
setTaskObject((prev: any) => ({
...prev,
taskStatus: '2'
}));
setCurrentStep('2');
// 获取分镜草图后,开始绘制角色
await getTaskRole(taskId);
// 修改 taskObject 下的 taskStatus 为 '3'
setTaskObject((prev: any) => ({
...prev,
taskStatus: '3'
}));
setCurrentStep('3');
// 获取绘制角色后,开始获取分镜视频
await getTaskVideo(taskId);
// 修改 taskObject 下的 taskStatus 为 '4'
setTaskObject((prev: any) => ({
...prev,
taskStatus: '4'
}));
setCurrentStep('4');
// 获取分镜视频后,开始获取背景音
await getTaskBackgroundAudio(taskId);
// 后期制作:抽卡中 对口型中 配音中 一致性处理中
setCurrentLoadingText(STEP_MESSAGES.postProduction('Selecting optimal frames'));
await new Promise(resolve => setTimeout(resolve, MOCK_DELAY_TIME.postProduction));
setCurrentLoadingText(STEP_MESSAGES.postProduction('Aligning lip sync'));
await new Promise(resolve => setTimeout(resolve, MOCK_DELAY_TIME.postProduction));
setCurrentLoadingText(STEP_MESSAGES.postProduction('Adding background audio'));
await new Promise(resolve => setTimeout(resolve, MOCK_DELAY_TIME.postProduction));
setCurrentLoadingText(STEP_MESSAGES.postProduction('Consistency processing'));
await new Promise(resolve => setTimeout(resolve, MOCK_DELAY_TIME.postProduction));
// 修改 taskObject 下的 taskStatus 为 '5'
setTaskObject((prev: any) => ({
...prev,
taskStatus: '5'
}));
setCurrentStep('5');
// 获取背景音后,开始获取最终成品
await getTaskFinalProduct(taskId);
await new Promise(resolve => setTimeout(resolve, 2000));
// 修改 taskObject 下的 taskStatus 为 '6'
setTaskObject((prev: any) => ({
...prev,
taskStatus: '6'
}));
setCurrentStep('6');
} catch (error) {
console.error('工作流初始化失败:', error);
setDataLoadError('工作流初始化失败,请刷新页面重试');
setIsLoading(false);
}
};
// 初始化数据
// 初始化
useEffect(() => {
initializeWorkflow();
}, []);
}, [episodeId]);
return {
// 状态数据
taskObject,
taskSketch,
taskVideos,
sketchCount,
isLoading: isLoading || isLoadingData, // 合并loading状态
isLoading,
currentStep,
currentSketchIndex,
isGeneratingSketch,
isGeneratingVideo,
currentLoadingText,
totalSketchCount: selectedMockData?.sketch?.length || 0,
roles: selectedMockData?.roles || [],
music: selectedMockData?.music || {},
final: selectedMockData?.final || {},
totalSketchCount,
roles,
music,
final,
dataLoadError,
// 操作方法
setCurrentSketchIndex,
retryLoadData,
};

View File

@ -91,17 +91,31 @@ const liquidbuttonVariants = cva(
}
)
function LiquidButton({
className,
variant,
size,
asChild = false,
children,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof liquidbuttonVariants> & {
type PolymorphicRef<C extends React.ElementType> = React.ComponentPropsWithRef<C>["ref"]
type AsProp<C extends React.ElementType> = {
as?: C
}
type PropsToOmit<C extends React.ElementType, P> = keyof (AsProp<C> & P)
type PolymorphicComponentProp<
C extends React.ElementType,
Props = {}
> = React.PropsWithChildren<Props & AsProp<C>> &
Omit<React.ComponentPropsWithoutRef<C>, PropsToOmit<C, Props>>
type PolymorphicComponentPropWithRef<
C extends React.ElementType,
Props = {}
> = PolymorphicComponentProp<C, Props> & { ref?: PolymorphicRef<C> }
interface LiquidButtonProps extends VariantProps<typeof liquidbuttonVariants> {
asChild?: boolean
}) {
}
const LiquidButton = React.forwardRef(
<C extends React.ElementType = "button">(
{ className, variant, size, asChild = false, children, ...props }: PolymorphicComponentPropWithRef<C, LiquidButtonProps>,
ref?: PolymorphicRef<C>
) => {
const Comp = asChild ? Slot : "button"
return (
@ -112,6 +126,7 @@ function LiquidButton({
"relative",
liquidbuttonVariants({ variant, size, className })
)}
ref={ref}
{...props}
>
<div className="absolute top-0 left-0 z-0 h-full w-full rounded-full
@ -131,6 +146,9 @@ function LiquidButton({
</>
)
}
);
(LiquidButton as any).displayName = "LiquidButton"
function GlassFilter() {

View File

@ -1,612 +0,0 @@
import { type CSSProperties, forwardRef, useCallback, useEffect, useId, useRef, useState } from "react"
import { ShaderDisplacementGenerator, fragmentShaders } from "./shader-utils"
import { displacementMap, polarDisplacementMap, prominentDisplacementMap } from "./utils"
// Generate shader-based displacement map using shaderUtils
const generateShaderDisplacementMap = (width: number, height: number): string => {
const generator = new ShaderDisplacementGenerator({
width,
height,
fragment: fragmentShaders.liquidGlass,
})
const dataUrl = generator.updateShader()
generator.destroy()
return dataUrl
}
const getMap = (mode: "standard" | "polar" | "prominent" | "shader", shaderMapUrl?: string) => {
switch (mode) {
case "standard":
return displacementMap
case "polar":
return polarDisplacementMap
case "prominent":
return prominentDisplacementMap
case "shader":
return shaderMapUrl || displacementMap
default:
throw new Error(`Invalid mode: ${mode}`)
}
}
/* ---------- SVG filter (edge-only displacement) ---------- */
const GlassFilter: React.FC<{ id: string; displacementScale: number; aberrationIntensity: number; width: number; height: number; mode: "standard" | "polar" | "prominent" | "shader"; shaderMapUrl?: string }> = ({
id,
displacementScale,
aberrationIntensity,
width,
height,
mode,
shaderMapUrl,
}) => (
<svg style={{ position: "absolute", width, height }} aria-hidden="true">
<defs>
<radialGradient id={`${id}-edge-mask`} cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor="black" stopOpacity="0" />
<stop offset={`${Math.max(30, 80 - aberrationIntensity * 2)}%`} stopColor="black" stopOpacity="0" />
<stop offset="100%" stopColor="white" stopOpacity="1" />
</radialGradient>
<filter id={id} x="-35%" y="-35%" width="170%" height="170%" colorInterpolationFilters="sRGB">
<feImage id="feimage" x="0" y="0" width="100%" height="100%" result="DISPLACEMENT_MAP" href={getMap(mode, shaderMapUrl)} preserveAspectRatio="xMidYMid slice" />
{/* Create edge mask using the displacement map itself */}
<feColorMatrix
in="DISPLACEMENT_MAP"
type="matrix"
values="0.3 0.3 0.3 0 0
0.3 0.3 0.3 0 0
0.3 0.3 0.3 0 0
0 0 0 1 0"
result="EDGE_INTENSITY"
/>
<feComponentTransfer in="EDGE_INTENSITY" result="EDGE_MASK">
<feFuncA type="discrete" tableValues={`0 ${aberrationIntensity * 0.05} 1`} />
</feComponentTransfer>
{/* Original undisplaced image for center */}
<feOffset in="SourceGraphic" dx="0" dy="0" result="CENTER_ORIGINAL" />
{/* Red channel displacement with slight offset */}
<feDisplacementMap in="SourceGraphic" in2="DISPLACEMENT_MAP" scale={displacementScale * (mode === "shader" ? 1 : -1)} xChannelSelector="R" yChannelSelector="B" result="RED_DISPLACED" />
<feColorMatrix
in="RED_DISPLACED"
type="matrix"
values="1 0 0 0 0
0 0 0 0 0
0 0 0 0 0
0 0 0 1 0"
result="RED_CHANNEL"
/>
{/* Green channel displacement */}
<feDisplacementMap in="SourceGraphic" in2="DISPLACEMENT_MAP" scale={displacementScale * ((mode === "shader" ? 1 : -1) - aberrationIntensity * 0.05)} xChannelSelector="R" yChannelSelector="B" result="GREEN_DISPLACED" />
<feColorMatrix
in="GREEN_DISPLACED"
type="matrix"
values="0 0 0 0 0
0 1 0 0 0
0 0 0 0 0
0 0 0 1 0"
result="GREEN_CHANNEL"
/>
{/* Blue channel displacement with slight offset */}
<feDisplacementMap in="SourceGraphic" in2="DISPLACEMENT_MAP" scale={displacementScale * ((mode === "shader" ? 1 : -1) - aberrationIntensity * 0.1)} xChannelSelector="R" yChannelSelector="B" result="BLUE_DISPLACED" />
<feColorMatrix
in="BLUE_DISPLACED"
type="matrix"
values="0 0 0 0 0
0 0 0 0 0
0 0 1 0 0
0 0 0 1 0"
result="BLUE_CHANNEL"
/>
{/* Combine all channels with screen blend mode for chromatic aberration */}
<feBlend in="GREEN_CHANNEL" in2="BLUE_CHANNEL" mode="screen" result="GB_COMBINED" />
<feBlend in="RED_CHANNEL" in2="GB_COMBINED" mode="screen" result="RGB_COMBINED" />
{/* Add slight blur to soften the aberration effect */}
<feGaussianBlur in="RGB_COMBINED" stdDeviation={Math.max(0.1, 0.5 - aberrationIntensity * 0.1)} result="ABERRATED_BLURRED" />
{/* Apply edge mask to aberration effect */}
<feComposite in="ABERRATED_BLURRED" in2="EDGE_MASK" operator="in" result="EDGE_ABERRATION" />
{/* Create inverted mask for center */}
<feComponentTransfer in="EDGE_MASK" result="INVERTED_MASK">
<feFuncA type="table" tableValues="1 0" />
</feComponentTransfer>
<feComposite in="CENTER_ORIGINAL" in2="INVERTED_MASK" operator="in" result="CENTER_CLEAN" />
{/* Combine edge aberration with clean center */}
<feComposite in="EDGE_ABERRATION" in2="CENTER_CLEAN" operator="over" />
</filter>
</defs>
</svg>
)
/* ---------- container ---------- */
const GlassContainer = forwardRef<
HTMLDivElement,
React.PropsWithChildren<{
className?: string
style?: React.CSSProperties
displacementScale?: number
blurAmount?: number
saturation?: number
aberrationIntensity?: number
mouseOffset?: { x: number; y: number }
onMouseLeave?: () => void
onMouseEnter?: () => void
onMouseDown?: () => void
onMouseUp?: () => void
active?: boolean
overLight?: boolean
cornerRadius?: number
padding?: string
glassSize?: { width: number; height: number }
onClick?: () => void
mode?: "standard" | "polar" | "prominent" | "shader"
}>
>(
(
{
children,
className = "",
style,
displacementScale = 25,
blurAmount = 12,
saturation = 180,
aberrationIntensity = 2,
onMouseEnter,
onMouseLeave,
onMouseDown,
onMouseUp,
active = false,
overLight = false,
cornerRadius = 999,
padding = "24px 32px",
glassSize = { width: 270, height: 69 },
onClick,
mode = "standard",
},
ref,
) => {
const filterId = useId()
const [shaderMapUrl, setShaderMapUrl] = useState<string>("")
const isFirefox = navigator.userAgent.toLowerCase().includes("firefox")
// Generate shader displacement map when in shader mode
useEffect(() => {
if (mode === "shader") {
const url = generateShaderDisplacementMap(glassSize.width, glassSize.height)
setShaderMapUrl(url)
}
}, [mode, glassSize.width, glassSize.height])
const backdropStyle = {
filter: isFirefox ? null : `url(#${filterId})`,
backdropFilter: `blur(${(overLight ? 12 : 4) + blurAmount * 32}px) saturate(${saturation}%)`,
}
return (
<div ref={ref} className={`relative ${className} ${active ? "active" : ""} ${Boolean(onClick) ? "cursor-pointer" : ""}`} style={style} onClick={onClick}>
<GlassFilter mode={mode} id={filterId} displacementScale={displacementScale} aberrationIntensity={aberrationIntensity} width={glassSize.width} height={glassSize.height} shaderMapUrl={shaderMapUrl} />
<div
className="glass"
style={{
borderRadius: `${cornerRadius}px`,
position: "relative",
display: "inline-flex",
alignItems: "center",
gap: "24px",
padding,
overflow: "hidden",
transition: "all 0.2s ease-in-out",
boxShadow: overLight ? "0px 16px 70px rgba(0, 0, 0, 0.75)" : "0px 12px 40px rgba(0, 0, 0, 0.25)",
}}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onMouseDown={onMouseDown}
onMouseUp={onMouseUp}
>
{/* backdrop layer that gets wiggly */}
<span
className="glass__warp"
style={
{
...backdropStyle,
position: "absolute",
inset: "0",
} as CSSProperties
}
/>
{/* user content stays sharp */}
<div
className="transition-all duration-150 ease-in-out text-white"
style={{
position: "relative",
zIndex: 1,
font: "500 20px/1 system-ui",
textShadow: overLight ? "0px 2px 12px rgba(0, 0, 0, 0)" : "0px 2px 12px rgba(0, 0, 0, 0.4)",
}}
>
{children}
</div>
</div>
</div>
)
},
)
GlassContainer.displayName = "GlassContainer"
interface LiquidGlassProps {
children: React.ReactNode
displacementScale?: number
blurAmount?: number
saturation?: number
aberrationIntensity?: number
elasticity?: number
cornerRadius?: number
globalMousePos?: { x: number; y: number }
mouseOffset?: { x: number; y: number }
mouseContainer?: React.RefObject<HTMLElement | null> | null
className?: string
padding?: string
style?: React.CSSProperties
overLight?: boolean
mode?: "standard" | "polar" | "prominent" | "shader"
onClick?: () => void
}
export default function LiquidGlass({
children,
displacementScale = 70,
blurAmount = 0.0625,
saturation = 140,
aberrationIntensity = 2,
elasticity = 0.15,
cornerRadius = 999,
globalMousePos: externalGlobalMousePos,
mouseOffset: externalMouseOffset,
mouseContainer = null,
className = "",
padding = "24px 32px",
overLight = false,
style = {},
mode = "standard",
onClick,
}: LiquidGlassProps) {
const glassRef = useRef<HTMLDivElement>(null)
const [isHovered, setIsHovered] = useState(false)
const [isActive, setIsActive] = useState(false)
const [glassSize, setGlassSize] = useState({ width: 270, height: 69 })
const [internalGlobalMousePos, setInternalGlobalMousePos] = useState({ x: 0, y: 0 })
const [internalMouseOffset, setInternalMouseOffset] = useState({ x: 0, y: 0 })
// Use external mouse position if provided, otherwise use internal
const globalMousePos = externalGlobalMousePos || internalGlobalMousePos
const mouseOffset = externalMouseOffset || internalMouseOffset
// Internal mouse tracking
const handleMouseMove = useCallback(
(e: MouseEvent) => {
const container = mouseContainer?.current || glassRef.current
if (!container) {
return
}
const rect = container.getBoundingClientRect()
const centerX = rect.left + rect.width / 2
const centerY = rect.top + rect.height / 2
setInternalMouseOffset({
x: ((e.clientX - centerX) / rect.width) * 100,
y: ((e.clientY - centerY) / rect.height) * 100,
})
setInternalGlobalMousePos({
x: e.clientX,
y: e.clientY,
})
},
[mouseContainer],
)
// Set up mouse tracking if no external mouse position is provided
useEffect(() => {
if (externalGlobalMousePos && externalMouseOffset) {
// External mouse tracking is provided, don't set up internal tracking
return
}
const container = mouseContainer?.current || glassRef.current
if (!container) {
return
}
container.addEventListener("mousemove", handleMouseMove)
return () => {
container.removeEventListener("mousemove", handleMouseMove)
}
}, [handleMouseMove, mouseContainer, externalGlobalMousePos, externalMouseOffset])
// Calculate directional scaling based on mouse position
const calculateDirectionalScale = useCallback(() => {
if (!globalMousePos.x || !globalMousePos.y || !glassRef.current) {
return "scale(1)"
}
const rect = glassRef.current.getBoundingClientRect()
const pillCenterX = rect.left + rect.width / 2
const pillCenterY = rect.top + rect.height / 2
const pillWidth = glassSize.width
const pillHeight = glassSize.height
const deltaX = globalMousePos.x - pillCenterX
const deltaY = globalMousePos.y - pillCenterY
// Calculate distance from mouse to pill edges (not center)
const edgeDistanceX = Math.max(0, Math.abs(deltaX) - pillWidth / 2)
const edgeDistanceY = Math.max(0, Math.abs(deltaY) - pillHeight / 2)
const edgeDistance = Math.sqrt(edgeDistanceX * edgeDistanceX + edgeDistanceY * edgeDistanceY)
// Activation zone: 200px from edges
const activationZone = 200
// If outside activation zone, no effect
if (edgeDistance > activationZone) {
return "scale(1)"
}
// Calculate fade-in factor (1 at edge, 0 at activation zone boundary)
const fadeInFactor = 1 - edgeDistance / activationZone
// Normalize the deltas for direction
const centerDistance = Math.sqrt(deltaX * deltaX + deltaY * deltaY)
if (centerDistance === 0) {
return "scale(1)"
}
const normalizedX = deltaX / centerDistance
const normalizedY = deltaY / centerDistance
// Calculate stretch factors with fade-in
const stretchIntensity = Math.min(centerDistance / 300, 1) * elasticity * fadeInFactor
// X-axis scaling: stretch horizontally when moving left/right, compress when moving up/down
const scaleX = 1 + Math.abs(normalizedX) * stretchIntensity * 0.3 - Math.abs(normalizedY) * stretchIntensity * 0.15
// Y-axis scaling: stretch vertically when moving up/down, compress when moving left/right
const scaleY = 1 + Math.abs(normalizedY) * stretchIntensity * 0.3 - Math.abs(normalizedX) * stretchIntensity * 0.15
return `scaleX(${Math.max(0.8, scaleX)}) scaleY(${Math.max(0.8, scaleY)})`
}, [globalMousePos, elasticity, glassSize])
// Helper function to calculate fade-in factor based on distance from element edges
const calculateFadeInFactor = useCallback(() => {
if (!globalMousePos.x || !globalMousePos.y || !glassRef.current) {
return 0
}
const rect = glassRef.current.getBoundingClientRect()
const pillCenterX = rect.left + rect.width / 2
const pillCenterY = rect.top + rect.height / 2
const pillWidth = glassSize.width
const pillHeight = glassSize.height
const edgeDistanceX = Math.max(0, Math.abs(globalMousePos.x - pillCenterX) - pillWidth / 2)
const edgeDistanceY = Math.max(0, Math.abs(globalMousePos.y - pillCenterY) - pillHeight / 2)
const edgeDistance = Math.sqrt(edgeDistanceX * edgeDistanceX + edgeDistanceY * edgeDistanceY)
const activationZone = 200
return edgeDistance > activationZone ? 0 : 1 - edgeDistance / activationZone
}, [globalMousePos, glassSize])
// Helper function to calculate elastic translation
const calculateElasticTranslation = useCallback(() => {
if (!glassRef.current) {
return { x: 0, y: 0 }
}
const fadeInFactor = calculateFadeInFactor()
const rect = glassRef.current.getBoundingClientRect()
const pillCenterX = rect.left + rect.width / 2
const pillCenterY = rect.top + rect.height / 2
return {
x: (globalMousePos.x - pillCenterX) * elasticity * 0.1 * fadeInFactor,
y: (globalMousePos.y - pillCenterY) * elasticity * 0.1 * fadeInFactor,
}
}, [globalMousePos, elasticity, calculateFadeInFactor])
// Update glass size whenever component mounts or window resizes
useEffect(() => {
const updateGlassSize = () => {
if (glassRef.current) {
const rect = glassRef.current.getBoundingClientRect()
setGlassSize({ width: rect.width, height: rect.height })
}
}
updateGlassSize()
window.addEventListener("resize", updateGlassSize)
return () => window.removeEventListener("resize", updateGlassSize)
}, [])
const transformStyle = `translate(calc(-50% + ${calculateElasticTranslation().x}px), calc(-50% + ${calculateElasticTranslation().y}px)) ${isActive && Boolean(onClick) ? "scale(0.96)" : calculateDirectionalScale()}`
const baseStyle = {
...style,
transform: transformStyle,
transition: "all ease-out 0.2s",
}
const positionStyles = {
position: baseStyle.position || "relative",
top: baseStyle.top || "50%",
left: baseStyle.left || "50%",
}
return (
<>
{/* Over light effect */}
<div
className={`bg-black transition-all duration-150 ease-in-out pointer-events-none ${overLight ? "opacity-20" : "opacity-0"}`}
style={{
...positionStyles,
height: glassSize.height,
width: glassSize.width,
borderRadius: `${cornerRadius}px`,
transform: baseStyle.transform,
transition: baseStyle.transition,
}}
/>
<div
className={`bg-black transition-all duration-150 ease-in-out pointer-events-none mix-blend-overlay ${overLight ? "opacity-100" : "opacity-0"}`}
style={{
...positionStyles,
height: glassSize.height,
width: glassSize.width,
borderRadius: `${cornerRadius}px`,
transform: baseStyle.transform,
transition: baseStyle.transition,
}}
/>
<GlassContainer
ref={glassRef}
className={className}
style={baseStyle}
cornerRadius={cornerRadius}
displacementScale={overLight ? displacementScale * 0.5 : displacementScale}
blurAmount={blurAmount}
saturation={saturation}
aberrationIntensity={aberrationIntensity}
glassSize={glassSize}
padding={padding}
mouseOffset={mouseOffset}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onMouseDown={() => setIsActive(true)}
onMouseUp={() => setIsActive(false)}
active={isActive}
overLight={overLight}
onClick={onClick}
mode={mode}
>
{children}
</GlassContainer>
{/* Border layer 1 - extracted from glass container */}
<span
style={{
...positionStyles,
height: glassSize.height,
width: glassSize.width,
borderRadius: `${cornerRadius}px`,
transform: baseStyle.transform,
transition: baseStyle.transition,
pointerEvents: "none",
mixBlendMode: "screen",
opacity: 0.2,
padding: "1.5px",
WebkitMask: "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)",
WebkitMaskComposite: "xor",
maskComposite: "exclude",
boxShadow: "0 0 0 0.5px rgba(255, 255, 255, 0.5) inset, 0 1px 3px rgba(255, 255, 255, 0.25) inset, 0 1px 4px rgba(0, 0, 0, 0.35)",
background: `linear-gradient(
${135 + mouseOffset.x * 1.2}deg,
rgba(255, 255, 255, 0.0) 0%,
rgba(255, 255, 255, ${0.12 + Math.abs(mouseOffset.x) * 0.008}) ${Math.max(10, 33 + mouseOffset.y * 0.3)}%,
rgba(255, 255, 255, ${0.4 + Math.abs(mouseOffset.x) * 0.012}) ${Math.min(90, 66 + mouseOffset.y * 0.4)}%,
rgba(255, 255, 255, 0.0) 100%
)`,
}}
/>
{/* Border layer 2 - duplicate with mix-blend-overlay */}
<span
style={{
...positionStyles,
height: glassSize.height,
width: glassSize.width,
borderRadius: `${cornerRadius}px`,
transform: baseStyle.transform,
transition: baseStyle.transition,
pointerEvents: "none",
mixBlendMode: "overlay",
padding: "1.5px",
WebkitMask: "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)",
WebkitMaskComposite: "xor",
maskComposite: "exclude",
boxShadow: "0 0 0 0.5px rgba(255, 255, 255, 0.5) inset, 0 1px 3px rgba(255, 255, 255, 0.25) inset, 0 1px 4px rgba(0, 0, 0, 0.35)",
background: `linear-gradient(
${135 + mouseOffset.x * 1.2}deg,
rgba(255, 255, 255, 0.0) 0%,
rgba(255, 255, 255, ${0.32 + Math.abs(mouseOffset.x) * 0.008}) ${Math.max(10, 33 + mouseOffset.y * 0.3)}%,
rgba(255, 255, 255, ${0.6 + Math.abs(mouseOffset.x) * 0.012}) ${Math.min(90, 66 + mouseOffset.y * 0.4)}%,
rgba(255, 255, 255, 0.0) 100%
)`,
}}
/>
{/* Hover effects */}
{Boolean(onClick) && (
<>
<div
style={{
...positionStyles,
height: glassSize.height,
width: glassSize.width + 1,
borderRadius: `${cornerRadius}px`,
transform: baseStyle.transform,
pointerEvents: "none",
transition: "all 0.2s ease-out",
opacity: isHovered || isActive ? 0.5 : 0,
backgroundImage: "radial-gradient(circle at 50% 0%, rgba(255, 255, 255, 0.5) 0%, rgba(255, 255, 255, 0) 50%)",
mixBlendMode: "overlay",
}}
/>
<div
style={{
...positionStyles,
height: glassSize.height,
width: glassSize.width + 1,
borderRadius: `${cornerRadius}px`,
transform: baseStyle.transform,
pointerEvents: "none",
transition: "all 0.2s ease-out",
opacity: isActive ? 0.5 : 0,
backgroundImage: "radial-gradient(circle at 50% 0%, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 0) 80%)",
mixBlendMode: "overlay",
}}
/>
<div
style={{
...baseStyle,
height: glassSize.height,
width: glassSize.width + 1,
borderRadius: `${cornerRadius}px`,
position: baseStyle.position,
top: baseStyle.top,
left: baseStyle.left,
pointerEvents: "none",
transition: "all 0.2s ease-out",
opacity: isHovered ? 0.4 : isActive ? 0.8 : 0,
backgroundImage: "radial-gradient(circle at 50% 0%, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 0) 100%)",
mixBlendMode: "overlay",
}}
/>
</>
)}
</>
)
}

View File

@ -1,134 +0,0 @@
// Adapted from https://github.com/shuding/liquid-glass
export interface Vec2 {
x: number
y: number
}
export interface ShaderOptions {
width: number
height: number
fragment: (uv: Vec2, mouse?: Vec2) => Vec2
mousePosition?: Vec2
}
function smoothStep(a: number, b: number, t: number): number {
t = Math.max(0, Math.min(1, (t - a) / (b - a)))
return t * t * (3 - 2 * t)
}
function length(x: number, y: number): number {
return Math.sqrt(x * x + y * y)
}
function roundedRectSDF(x: number, y: number, width: number, height: number, radius: number): number {
const qx = Math.abs(x) - width + radius
const qy = Math.abs(y) - height + radius
return Math.min(Math.max(qx, qy), 0) + length(Math.max(qx, 0), Math.max(qy, 0)) - radius
}
function texture(x: number, y: number): Vec2 {
return { x, y }
}
// Shader fragment functions for different effects
export const fragmentShaders = {
liquidGlass: (uv: Vec2): Vec2 => {
const ix = uv.x - 0.5
const iy = uv.y - 0.5
const distanceToEdge = roundedRectSDF(ix, iy, 0.3, 0.2, 0.6)
const displacement = smoothStep(0.8, 0, distanceToEdge - 0.15)
const scaled = smoothStep(0, 1, displacement)
return texture(ix * scaled + 0.5, iy * scaled + 0.5)
},
}
export type FragmentShaderType = keyof typeof fragmentShaders
export class ShaderDisplacementGenerator {
private canvas: HTMLCanvasElement
private context: CanvasRenderingContext2D
private canvasDPI = 1
constructor(private options: ShaderOptions) {
this.canvas = document.createElement("canvas")
this.canvas.width = options.width * this.canvasDPI
this.canvas.height = options.height * this.canvasDPI
this.canvas.style.display = "none"
const context = this.canvas.getContext("2d")
if (!context) {
throw new Error("Could not get 2D context")
}
this.context = context
}
updateShader(mousePosition?: Vec2): string {
const w = this.options.width * this.canvasDPI
const h = this.options.height * this.canvasDPI
let maxScale = 0
const rawValues: number[] = []
// Calculate displacement values
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const uv: Vec2 = { x: x / w, y: y / h }
const pos = this.options.fragment(uv, mousePosition)
const dx = pos.x * w - x
const dy = pos.y * h - y
maxScale = Math.max(maxScale, Math.abs(dx), Math.abs(dy))
rawValues.push(dx, dy)
}
}
// Improved normalization to prevent artifacts while maintaining intensity
if (maxScale > 0) {
maxScale = Math.max(maxScale, 1) // Ensure minimum scale to prevent over-normalization
} else {
maxScale = 1
}
// Create ImageData and fill it
const imageData = this.context.createImageData(w, h)
const data = imageData.data
// Convert to image data with smoother normalization
let rawIndex = 0
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const dx = rawValues[rawIndex++]
const dy = rawValues[rawIndex++]
// Smooth the displacement values at edges to prevent hard transitions
const edgeDistance = Math.min(x, y, w - x - 1, h - y - 1)
const edgeFactor = Math.min(1, edgeDistance / 2) // Smooth within 2 pixels of edge
const smoothedDx = dx * edgeFactor
const smoothedDy = dy * edgeFactor
const r = smoothedDx / maxScale + 0.5
const g = smoothedDy / maxScale + 0.5
const pixelIndex = (y * w + x) * 4
data[pixelIndex] = Math.max(0, Math.min(255, r * 255)) // Red channel (X displacement)
data[pixelIndex + 1] = Math.max(0, Math.min(255, g * 255)) // Green channel (Y displacement)
data[pixelIndex + 2] = Math.max(0, Math.min(255, g * 255)) // Blue channel (Y displacement for SVG filter compatibility)
data[pixelIndex + 3] = 255 // Alpha channel
}
}
this.context.putImageData(imageData, 0, 0)
return this.canvas.toDataURL()
}
destroy(): void {
this.canvas.remove()
}
getScale(): number {
return this.canvasDPI
}
}

File diff suppressed because one or more lines are too long