Files
video-gen/video-gen-app/src/components/generation/GenerationTaskResourceGrid.tsx
T

357 lines
14 KiB
TypeScript

import React, { useState, useEffect, useRef } from 'react';
import { LoadingOutlined, PlayCircleFilled, WarningOutlined } from '@ant-design/icons';
export interface GenerationTaskResourceItem {
id?: string;
genType?: string;
status?: string;
displayStatus?: string | null;
pipelineStage?: string | null;
imageUrl?: string | null;
videoUrl?: string | null;
videoCoverUrl?: string | null;
errorMessage?: string | null;
generationIndex?: number | null;
createdAt?: string;
}
export interface GenerationTaskResourceGroup extends GenerationTaskResourceItem {
generationCount?: number | null;
childItems?: GenerationTaskResourceItem[] | null;
}
interface Props {
task: GenerationTaskResourceGroup;
onPreview: (url: string, type: 'image' | 'video') => void;
resolveUrl?: (url?: string | null) => string;
}
const spanByCount = (count: number, index: number): number => {
if (count <= 1) return 6;
if (count === 2 || count === 4) return 3;
if (count === 3) return 3;
return 2;
};
const statusText = (item: GenerationTaskResourceItem): string => {
const status = item.displayStatus || item.pipelineStage || item.status || 'generating';
const labels: Record<string, string> = {
pending: '待处理', queued: '排队中', preparing: '准备中', generating: '生成中',
creating_provider_task: '创建任务中', waiting_remote: '等待生成', polling: '轮询中',
result_ready: '结果就绪', download_queued: '等待下载', downloading: '下载中',
retry_waiting: '等待重试', completed: '已完成', failed: '生成失败',
download_failed: '下载失败', deleted: '已删除',
};
return labels[status] || status;
};
const isPending = (item: GenerationTaskResourceItem): boolean => {
const status = item.displayStatus || item.pipelineStage || item.status;
return !status || ['pending', 'queued', 'preparing', 'generating', 'creating_provider_task', 'waiting_remote', 'polling', 'result_ready', 'download_queued', 'downloading', 'retry_waiting'].includes(status);
};
const MAX_DURATION_SECONDS = 300;
interface ProgressItemProps {
item: GenerationTaskResourceItem;
isPending: boolean;
isCompleted: boolean;
onAnimationComplete?: () => void;
onProgressChange?: (progress: number) => void;
}
const PROGRESS_RATE = 0.6;
const calculateProgressValue = (createdAt?: string): number => {
if (!createdAt) return 0;
const createdTime = new Date(createdAt).getTime();
if (isNaN(createdTime)) return 0;
const now = Date.now();
const elapsedSeconds = Math.max(0, (now - createdTime) / 1000);
if (elapsedSeconds >= MAX_DURATION_SECONDS) {
return 99;
}
return Math.min(99, elapsedSeconds * PROGRESS_RATE);
};
const ProgressItem: React.FC<ProgressItemProps> = ({ item, isPending: isPendingProp, isCompleted, onAnimationComplete, onProgressChange }) => {
const [progress, setProgress] = useState<number>(0);
const [displayProgress, setDisplayProgress] = useState<number>(0);
const [isFinishing, setIsFinishing] = useState(false);
const intervalRef = useRef<number | null>(null);
useEffect(() => {
if (onProgressChange) {
onProgressChange(displayProgress);
}
}, [displayProgress, onProgressChange]);
useEffect(() => {
const newProgress = calculateProgressValue(item.createdAt);
setProgress(newProgress);
setDisplayProgress(newProgress);
}, [item]);
useEffect(() => {
if (isCompleted && !isFinishing) {
setIsFinishing(true);
return;
}
if (!isPendingProp && !isFinishing) {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
return;
}
const updateProgress = () => {
const newProgress = calculateProgressValue(item.createdAt);
setProgress(newProgress);
setDisplayProgress(newProgress);
};
intervalRef.current = window.setInterval(updateProgress, 1000);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, [item.createdAt, isPendingProp, isCompleted, isFinishing, item.id]);
useEffect(() => {
if (isFinishing) {
const targetProgress = 100;
const currentProgress = displayProgress;
const duration = 800;
const startTime = Date.now();
const animate = () => {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
const easeProgress = 1 - Math.pow(1 - progress, 3);
const newProgress = currentProgress + (targetProgress - currentProgress) * easeProgress;
setDisplayProgress(newProgress);
if (progress < 1) {
requestAnimationFrame(animate);
} else {
setTimeout(() => {
onAnimationComplete?.();
}, 100);
}
};
requestAnimationFrame(animate);
}
}, [isFinishing, displayProgress, onAnimationComplete]);
if (!isPendingProp && !isFinishing) return null;
return (
<div style={{
marginTop: 4,
}}>
<div style={{
textAlign: 'center',
fontSize: 13,
color: '#8b5cf6',
fontWeight: 700,
textShadow: '0 0 8px rgba(139, 92, 246, 0.3)',
transition: 'all 0.3s ease-out',
}}>
{Math.round(displayProgress)}%
</div>
</div>
);
};
const GenerationTaskResourceGrid: React.FC<Props> = ({ task, onPreview, resolveUrl = (url) => url || '' }) => {
const count = Math.max(1, Math.min(5, Number(task.generationCount || task.childItems?.length || 1)));
const children = [...(task.childItems || [])].sort((a, b) => Number(a.generationIndex || 0) - Number(b.generationIndex || 0));
const items: GenerationTaskResourceItem[] = children.length > 0
? children
: (count > 1 ? Array.from({ length: count }, (_, index) => ({
id: `${task.id || 'task'}-placeholder-${index + 1}`,
genType: task.genType,
status: task.status,
displayStatus: task.displayStatus,
pipelineStage: task.pipelineStage,
generationIndex: index + 1,
errorMessage: task.errorMessage,
createdAt: task.createdAt,
})) : [task]);
const [finishingItems, setFinishingItems] = useState<Set<string>>(new Set());
const [fullProgressItems, setFullProgressItems] = useState<Set<string>>(new Set());
const processedItems = useRef<Set<string>>(new Set());
useEffect(() => {
const styleId = 'gen-task-resource-grid-animations';
if (document.getElementById(styleId)) return;
const style = document.createElement('style');
style.id = styleId;
style.textContent = `
.gen-task-loading-bg {
position: relative;
overflow: hidden;
background: linear-gradient(135deg, #faf8ff 0%, #f5f3ff 100%);
}
.gen-task-loading-bg::before,
.gen-task-loading-bg::after,
.gen-task-loading-bg .gen-task-aurora-3 {
content: '';
position: absolute;
border-radius: 50%;
filter: blur(30px);
pointer-events: none;
z-index: 0;
}
.gen-task-loading-bg::before {
width: 220px;
height: 220px;
background: radial-gradient(circle, rgba(139, 92, 246, 0.45) 0%, rgba(168, 85, 247, 0.25) 50%, transparent 100%);
animation: genTaskAurora1 6s linear infinite;
}
.gen-task-loading-bg::after {
width: 240px;
height: 240px;
background: radial-gradient(circle, rgba(168, 85, 247, 0.35) 0%, rgba(192, 132, 252, 0.2) 50%, transparent 100%);
animation: genTaskAurora2 7.5s linear infinite;
}
.gen-task-loading-bg .gen-task-aurora-3 {
width: 180px;
height: 180px;
background: radial-gradient(circle, rgba(99, 102, 241, 0.3) 0%, rgba(139, 92, 246, 0.15) 50%, transparent 100%);
animation: genTaskAurora3 5s linear infinite;
}
.gen-task-loading-bg > * { position: relative; z-index: 1; }
@keyframes genTaskAurora1 {
0% { transform: translate(-100px, -80px); }
25% { transform: translate(100px, -60px); }
50% { transform: translate(120px, 80px); }
75% { transform: translate(-60px, 100px); }
100% { transform: translate(-100px, -80px); }
}
@keyframes genTaskAurora2 {
0% { transform: translate(120px, 100px); }
25% { transform: translate(-80px, 120px); }
50% { transform: translate(-100px, -60px); }
75% { transform: translate(80px, -80px); }
100% { transform: translate(120px, 100px); }
}
@keyframes genTaskAurora3 {
0% { transform: translate(60px, -40px); }
25% { transform: translate(-40px, 60px); }
50% { transform: translate(40px, 100px); }
75% { transform: translate(100px, -20px); }
100% { transform: translate(60px, -40px); }
}
`;
document.head.appendChild(style);
}, []);
const handleProgressChange = (itemId: string, progress: number) => {
if (progress >= 100) {
setFullProgressItems(prev => {
if (prev.has(itemId)) return prev;
const next = new Set(prev);
next.add(itemId);
return next;
});
}
};
useEffect(() => {
items.slice(0, 5).forEach((item, index) => {
const displayStatus = item.displayStatus || item.pipelineStage || item.status || 'generating';
const pending = isPending(item);
const completed = !pending && displayStatus !== 'failed' && displayStatus !== 'download_failed' && displayStatus !== 'deleted';
const itemId = item.id || `${index}`;
if (completed && !processedItems.current.has(itemId) && pending === false) {
processedItems.current.add(itemId);
setFinishingItems(prev => {
const next = new Set(prev);
next.add(itemId);
return next;
});
}
});
}, [items]);
const handleFinishAnimation = (itemId: string) => {
setFinishingItems(prev => {
const next = new Set(prev);
next.delete(itemId);
return next;
});
};
return (
<div style={{ width: '100%', height: '100%', display: 'grid', gridTemplateColumns: 'repeat(6, minmax(0, 1fr))', gridAutoRows: 'minmax(0, 1fr)', gap: count > 1 ? 6 : 0 }}>
{items.slice(0, 5).map((item, index) => {
const displayStatus = item.displayStatus || item.pipelineStage || item.status || 'generating';
const imageUrl = resolveUrl(`/static${item.imageUrl}&w=300&q=50`);
const videoUrl = resolveUrl(item.videoUrl);
const coverUrl = resolveUrl(`/static${item.videoCoverUrl}&w=300&q=50`);
const isVideo = (item.genType || task.genType) === 'video';
const hasResource = isVideo ? !!videoUrl : !!imageUrl;
const pending = isPending(item);
const completed = !pending && displayStatus !== 'failed' && displayStatus !== 'download_failed' && displayStatus !== 'deleted';
const itemId = item.id || `${index}`;
const isFinishing = finishingItems.has(itemId);
const isFullProgress = fullProgressItems.has(itemId);
const shouldShowContent = completed && !isFinishing;
const statusLabel = isFinishing ? '加载中' : statusText(item);
return (
<div
key={item.id || `${index}`}
style={{
gridColumn: `span ${spanByCount(items.length, index)}`,
minWidth: 0,
minHeight: 0,
position: 'relative',
overflow: 'hidden',
borderRadius: items.length === 1 ? 12 : 8,
background: '#ffffff',
border: '1px solid #E7EAF0',
}}
>
{hasResource && displayStatus !== 'deleted' && displayStatus !== 'failed' && displayStatus !== 'download_failed' && shouldShowContent ? (
<button
type="button"
onClick={() => onPreview(isVideo ? videoUrl : imageUrl, isVideo ? 'video' : 'image')}
style={{ width: '100%', height: '100%', border: 0, padding: 0, background: 'transparent', cursor: 'pointer', position: 'relative' }}
>
{isVideo ? (
coverUrl ? <img src={coverUrl} alt={`生成结果${item.generationIndex || index + 1}`} style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
: <video src={videoUrl} muted preload="metadata" style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
) : (
<img src={imageUrl} alt={`生成结果${item.generationIndex || index + 1}`} style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
)}
{isVideo ? <PlayCircleFilled style={{ position: 'absolute', left: '50%', top: '50%', transform: 'translate(-50%, -50%)', fontSize: items.length > 2 ? 28 : 46, color: 'rgba(255,255,255,.92)', filter: 'drop-shadow(0 4px 10px rgba(0,0,0,.28))' }} /> : null}
</button>
) : (
<div className={pending || isFinishing ? 'gen-task-loading-bg' : ''} style={{ width: '100%', height: '100%', minHeight: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 7, padding: 8, textAlign: 'center' }}>
{pending || isFinishing ? <span className="gen-task-aurora-3" /> : null}
{pending || isFinishing ? <LoadingOutlined spin style={{ color: '#8b5cf6', fontSize: items.length > 2 ? 20 : 34 }} /> : <WarningOutlined style={{ color: displayStatus === 'deleted' ? '#98A2B3' : '#A45B5B', fontSize: items.length > 2 ? 20 : 34 }} />}
<span style={{ fontSize: items.length > 2 ? 10 : 12, color: pending || isFinishing ? '#8b5cf6' : (displayStatus === 'deleted' ? '#98A2B3' : '#A45B5B'), fontWeight: 500 }}>{statusLabel}</span>
<ProgressItem item={item} isPending={pending || isFinishing} isCompleted={completed} onAnimationComplete={() => handleFinishAnimation(itemId)} onProgressChange={(progress) => handleProgressChange(itemId, progress)} />
{!pending && item.errorMessage && items.length <= 2 ? <span style={{ fontSize: 10, color: '#A45B5B', lineHeight: 1.3, maxHeight: 28, overflow: 'hidden' }}>{item.errorMessage}</span> : null}
</div>
)}
{items.length > 1 ? <span style={{ position: 'absolute', top: 5, left: 5, zIndex: 2, padding: '1px 6px', borderRadius: 10, background: 'rgba(17,24,39,.58)', color: '#fff', fontSize: 10 }}>#{item.generationIndex || index + 1}</span> : null}
</div>
);
})}
</div>
);
};
export default GenerationTaskResourceGrid;