forked from 77media/video-flow
精简项目以及修复构建问题
This commit is contained in:
parent
4c5be13b0f
commit
bc64add617
@ -1,5 +0,0 @@
|
||||
import { ScriptToVideo } from '@/components/pages/script-to-video';
|
||||
|
||||
export default function ScriptToVideoPage() {
|
||||
return <ScriptToVideo />;
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
import { VideoToVideo } from '@/components/pages/video-to-video';
|
||||
|
||||
export default function VideoToVideoPage() {
|
||||
return <VideoToVideo />;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
// 动画2:ImageWave 动画展示
|
||||
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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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");
|
||||
}}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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",
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user