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 { DashboardLayout } from '@/components/layout/dashboard-layout';
|
||||||
import { HistoryPage } from '@/components/pages/history-page';
|
|
||||||
|
|
||||||
export default function History() {
|
export default function History() {
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
<HistoryPage />
|
<div>
|
||||||
|
<h1>History</h1>
|
||||||
|
</div>
|
||||||
</DashboardLayout>
|
</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 = {
|
const projectData: CreateScriptProjectRequest = {
|
||||||
title: "script default", // 默认剧本名称
|
title: "script default", // 默认剧本名称
|
||||||
project_type: projectType,
|
project_type: projectType,
|
||||||
mode: ModeEnum.MANUAL,
|
mode: ModeEnum.MANUAL === 'manual' ? 1 : 2, // 1 表示手动模式,2 表示自动模式
|
||||||
resolution: ResolutionEnum.FULL_HD_1080P
|
resolution: 1080 // 1080p 分辨率
|
||||||
};
|
};
|
||||||
|
|
||||||
const projectResponse = await createScriptProject(projectData);
|
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">
|
<div className="absolute top-[8rem] z-[50] right-6 w-[8rem] flex justify-end">
|
||||||
<LiquidButton className="w-[8rem] h-[3rem] text-sm"
|
<LiquidButton className="w-[8rem] h-[3rem] text-sm"
|
||||||
onClick={(e) => {
|
onClick={(e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleToolChange(activeTool === "stretch" ? "right" : "left");
|
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 { TaskInfo } from "./work-flow/task-info";
|
||||||
import { MediaViewer } from "./work-flow/media-viewer";
|
import { MediaViewer } from "./work-flow/media-viewer";
|
||||||
import { ThumbnailGrid } from "./work-flow/thumbnail-grid";
|
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 { usePlaybackControls } from "./work-flow/use-playback-controls";
|
||||||
import { AlertCircle, RefreshCw } from "lucide-react";
|
import { AlertCircle, RefreshCw } from "lucide-react";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { detailScriptEpisodeNew, getScriptTitle, getRunningStreamData, StreamData } from '@/api/video_flow';
|
import { detailScriptEpisodeNew, getScriptTitle, getRunningStreamData, StreamData } from '@/api/video_flow';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
import { ApiResponse } from '@/api/common';
|
||||||
|
|
||||||
// 步骤映射
|
// 步骤映射
|
||||||
const STEP_MAP = {
|
const STEP_MAP = {
|
||||||
@ -56,8 +57,8 @@ export const useApiData = () => {
|
|||||||
const [streamInterval, setStreamInterval] = useState<NodeJS.Timeout | null>(null);
|
const [streamInterval, setStreamInterval] = useState<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
// 处理流式数据
|
// 处理流式数据
|
||||||
const handleStreamData = useCallback((streamData: StreamData) => {
|
const handleStreamData = useCallback((streamData: ApiResponse<StreamData>) => {
|
||||||
const { category, message, data, status } = streamData;
|
const { category, message, data, status } = streamData.data;
|
||||||
|
|
||||||
// 更新加载文本
|
// 更新加载文本
|
||||||
setCurrentLoadingText(message);
|
setCurrentLoadingText(message);
|
||||||
@ -114,7 +115,7 @@ export const useApiData = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 如果状态为 completed,停止获取流式数据
|
// 如果状态为 completed,停止获取流式数据
|
||||||
if (status === 'completed' || streamData.all_completed) {
|
if (status === 'completed' || streamData.data.all_completed) {
|
||||||
setNeedStreamData(false);
|
setNeedStreamData(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
@ -124,7 +125,7 @@ export const useApiData = () => {
|
|||||||
if (!episodeId || !needStreamData) return;
|
if (!episodeId || !needStreamData) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const streamData = await getRunningStreamData({ episodeId });
|
const streamData = await getRunningStreamData({ project_id: episodeId });
|
||||||
handleStreamData(streamData);
|
handleStreamData(streamData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取流式数据失败:', 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';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { getRandomMockData, STEP_MESSAGES, MOCK_DELAY_TIME, MOCK_DATA } from '@/components/work-flow/constants';
|
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() {
|
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 [taskSketch, setTaskSketch] = useState<any[]>([]);
|
||||||
const [taskRoles, setTaskRoles] = useState<any[]>([]);
|
|
||||||
const [taskVideos, setTaskVideos] = useState<any[]>([]);
|
const [taskVideos, setTaskVideos] = useState<any[]>([]);
|
||||||
const [sketchCount, setSketchCount] = useState(0);
|
const [sketchCount, setSketchCount] = useState(0);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
@ -18,321 +59,347 @@ export function useWorkflowData() {
|
|||||||
const [isGeneratingSketch, setIsGeneratingSketch] = useState(false);
|
const [isGeneratingSketch, setIsGeneratingSketch] = useState(false);
|
||||||
const [isGeneratingVideo, setIsGeneratingVideo] = useState(false);
|
const [isGeneratingVideo, setIsGeneratingVideo] = useState(false);
|
||||||
const [currentLoadingText, setCurrentLoadingText] = useState('正在加载项目数据...');
|
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 [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 {
|
try {
|
||||||
setIsLoadingData(true);
|
const response = await getRunningStreamData({ project_id: episodeId });
|
||||||
setDataLoadError(null);
|
if (!response.successful) {
|
||||||
setCurrentLoadingText('正在从服务器获取项目数据...');
|
throw new Error(response.message);
|
||||||
|
}
|
||||||
|
|
||||||
// 尝试从接口获取数据
|
let loadingText: any = LOADING_TEXT_MAP.initializing;
|
||||||
selectedMockData = await getRandomMockData();
|
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);
|
console.log('all_task_data', all_task_data);
|
||||||
setCurrentLoadingText('项目数据加载完成');
|
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) {
|
} catch (error) {
|
||||||
// 报错
|
console.error('获取数据失败:', error);
|
||||||
} finally {
|
}
|
||||||
setIsLoadingData(false);
|
};
|
||||||
|
|
||||||
|
// 轮询获取流式数据
|
||||||
|
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 () => {
|
const retryLoadData = () => {
|
||||||
console.log('用户点击重试,重新加载数据...');
|
|
||||||
selectedMockData = null; // 重置数据
|
|
||||||
setDataLoadError(null);
|
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([]);
|
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([]);
|
setTaskVideos([]);
|
||||||
|
setSketchCount(0);
|
||||||
const videoData = selectedMockData.video;
|
setTotalSketchCount(0);
|
||||||
const totalVideos = videoData.length;
|
setRoles([]);
|
||||||
|
setMusic([]);
|
||||||
// 模拟分批获取分镜视频
|
setFinal(null);
|
||||||
for (let i = 0; i < totalVideos; i++) {
|
setCurrentSketchIndex(0);
|
||||||
await new Promise(resolve => setTimeout(resolve, MOCK_DELAY_TIME.video)); // 60s
|
setCurrentStep('0');
|
||||||
|
// 重新初始化
|
||||||
const newVideo = {
|
initializeWorkflow();
|
||||||
id: `video-${i}`,
|
|
||||||
url: videoData[i].url,
|
|
||||||
script: videoData[i].script,
|
|
||||||
status: 'done'
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
initializeWorkflow();
|
initializeWorkflow();
|
||||||
}, []);
|
}, [episodeId]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// 状态数据
|
|
||||||
taskObject,
|
taskObject,
|
||||||
taskSketch,
|
taskSketch,
|
||||||
taskVideos,
|
taskVideos,
|
||||||
sketchCount,
|
sketchCount,
|
||||||
isLoading: isLoading || isLoadingData, // 合并loading状态
|
isLoading,
|
||||||
currentStep,
|
currentStep,
|
||||||
currentSketchIndex,
|
currentSketchIndex,
|
||||||
isGeneratingSketch,
|
isGeneratingSketch,
|
||||||
isGeneratingVideo,
|
isGeneratingVideo,
|
||||||
currentLoadingText,
|
currentLoadingText,
|
||||||
totalSketchCount: selectedMockData?.sketch?.length || 0,
|
totalSketchCount,
|
||||||
roles: selectedMockData?.roles || [],
|
roles,
|
||||||
music: selectedMockData?.music || {},
|
music,
|
||||||
final: selectedMockData?.final || {},
|
final,
|
||||||
dataLoadError,
|
dataLoadError,
|
||||||
// 操作方法
|
|
||||||
setCurrentSketchIndex,
|
setCurrentSketchIndex,
|
||||||
retryLoadData,
|
retryLoadData,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -91,17 +91,31 @@ const liquidbuttonVariants = cva(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
function LiquidButton({
|
type PolymorphicRef<C extends React.ElementType> = React.ComponentPropsWithRef<C>["ref"]
|
||||||
className,
|
type AsProp<C extends React.ElementType> = {
|
||||||
variant,
|
as?: C
|
||||||
size,
|
}
|
||||||
asChild = false,
|
type PropsToOmit<C extends React.ElementType, P> = keyof (AsProp<C> & P)
|
||||||
children,
|
type PolymorphicComponentProp<
|
||||||
...props
|
C extends React.ElementType,
|
||||||
}: React.ComponentProps<"button"> &
|
Props = {}
|
||||||
VariantProps<typeof liquidbuttonVariants> & {
|
> = 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
|
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"
|
const Comp = asChild ? Slot : "button"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -112,6 +126,7 @@ function LiquidButton({
|
|||||||
"relative",
|
"relative",
|
||||||
liquidbuttonVariants({ variant, size, className })
|
liquidbuttonVariants({ variant, size, className })
|
||||||
)}
|
)}
|
||||||
|
ref={ref}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<div className="absolute top-0 left-0 z-0 h-full w-full rounded-full
|
<div className="absolute top-0 left-0 z-0 h-full w-full rounded-full
|
||||||
@ -130,7 +145,10 @@ function LiquidButton({
|
|||||||
</Comp>
|
</Comp>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
(LiquidButton as any).displayName = "LiquidButton"
|
||||||
|
|
||||||
|
|
||||||
function GlassFilter() {
|
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