forked from 77media/video-flow
382 lines
14 KiB
TypeScript
382 lines
14 KiB
TypeScript
'use client';
|
|
|
|
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
|
import { motion } from 'framer-motion';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { ProgressiveReveal, presets } from '@/components/ui/progressive-reveal';
|
|
import { Loader2, X } from 'lucide-react';
|
|
import { TaskObject } from '@/api/DTO/movieEdit';
|
|
|
|
interface ThumbnailGridProps {
|
|
taskObject: TaskObject;
|
|
isLoading: boolean;
|
|
currentSketchIndex: number;
|
|
taskSketch: any[];
|
|
taskVideos: any[];
|
|
isGeneratingSketch: boolean;
|
|
isGeneratingVideo: boolean;
|
|
sketchCount: number;
|
|
totalSketchCount: number;
|
|
onSketchSelect: (index: number) => void;
|
|
}
|
|
|
|
export function ThumbnailGrid({
|
|
taskObject,
|
|
isLoading,
|
|
currentSketchIndex,
|
|
taskSketch,
|
|
taskVideos,
|
|
isGeneratingSketch,
|
|
isGeneratingVideo,
|
|
sketchCount,
|
|
totalSketchCount,
|
|
onSketchSelect
|
|
}: ThumbnailGridProps) {
|
|
const thumbnailsRef = useRef<HTMLDivElement>(null);
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const [startX, setStartX] = useState(0);
|
|
const [scrollLeft, setScrollLeft] = useState(0);
|
|
const [isFocused, setIsFocused] = useState(false);
|
|
|
|
// 监听当前选中索引变化,自动滚动到对应位置
|
|
useEffect(() => {
|
|
if (thumbnailsRef.current) {
|
|
const container = thumbnailsRef.current;
|
|
const thumbnailWidth = container.offsetWidth / 4; // 每个缩略图宽度(包含间距)
|
|
const scrollPosition = currentSketchIndex * thumbnailWidth;
|
|
|
|
container.scrollTo({
|
|
left: scrollPosition,
|
|
behavior: 'smooth'
|
|
});
|
|
}
|
|
}, [currentSketchIndex]);
|
|
|
|
// 获取当前阶段的数据数组
|
|
const getCurrentData = useCallback(() => {
|
|
if (taskObject.currentStage === 'video') {
|
|
return taskObject.videos.data;
|
|
} else if (taskObject.currentStage === 'scene' || taskObject.currentStage === 'character') {
|
|
return taskObject.scenes.data;
|
|
} else if (taskObject.currentStage === 'shot_sketch') {
|
|
return taskObject.shot_sketch.data;
|
|
}
|
|
return [];
|
|
}, [taskObject.currentStage, taskObject.videos.data, taskObject.scenes.data, taskObject.shot_sketch.data]);
|
|
|
|
// 使用 useRef 存储前一次的数据,避免触发重渲染
|
|
const prevDataRef = useRef<any[]>([]);
|
|
|
|
useEffect(() => {
|
|
const currentData = getCurrentData();
|
|
if (currentData && currentData.length > 0) {
|
|
const currentDataStr = JSON.stringify(currentData);
|
|
const prevDataStr = JSON.stringify(prevDataRef.current);
|
|
|
|
// 只有当数据真正发生变化时才进行处理
|
|
if (currentDataStr !== prevDataStr) {
|
|
// 找到最新更新的数据项的索引
|
|
const changedIndex = currentData.findIndex((item, index) => {
|
|
// 检查是否是新增的数据
|
|
if (index >= prevDataRef.current.length) return true;
|
|
// 检查数据是否发生变化(包括状态变化)
|
|
return JSON.stringify(item) !== JSON.stringify(prevDataRef.current[index]);
|
|
});
|
|
|
|
console.log('changedIndex_thumbnail-grid', changedIndex, 'currentData:', currentData, 'prevData:', prevDataRef.current);
|
|
|
|
// 如果找到变化的项,自动选择该项
|
|
if (changedIndex !== -1) {
|
|
onSketchSelect(changedIndex);
|
|
}
|
|
|
|
// 更新前一次的数据快照
|
|
prevDataRef.current = JSON.parse(JSON.stringify(currentData));
|
|
}
|
|
}
|
|
}, [taskObject, getCurrentData, onSketchSelect]);
|
|
|
|
// 处理键盘左右键事件
|
|
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
|
const currentData = getCurrentData();
|
|
const maxIndex = currentData.length - 1;
|
|
console.log('handleKeyDown', maxIndex, 'isFocused:', isFocused);
|
|
|
|
if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && maxIndex >= 0) {
|
|
e.preventDefault();
|
|
|
|
let newIndex = currentSketchIndex;
|
|
if (e.key === 'ArrowLeft') {
|
|
// 向左循环
|
|
newIndex = currentSketchIndex === 0 ? maxIndex : currentSketchIndex - 1;
|
|
} else {
|
|
// 向右循环
|
|
newIndex = currentSketchIndex === maxIndex ? 0 : currentSketchIndex + 1;
|
|
}
|
|
|
|
console.log('切换索引:', currentSketchIndex, '->', newIndex, '最大索引:', maxIndex);
|
|
onSketchSelect(newIndex);
|
|
}
|
|
}, [isFocused, currentSketchIndex, onSketchSelect, getCurrentData]);
|
|
|
|
// 监听键盘事件
|
|
useEffect(() => {
|
|
// 组件挂载时自动聚焦
|
|
if (thumbnailsRef.current) {
|
|
thumbnailsRef.current.focus();
|
|
}
|
|
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, [handleKeyDown]);
|
|
|
|
// 确保在数据变化时保持焦点
|
|
useEffect(() => {
|
|
if (thumbnailsRef.current && !isFocused) {
|
|
thumbnailsRef.current.focus();
|
|
}
|
|
}, [taskObject.currentStage, isFocused]);
|
|
|
|
// 处理鼠标/触摸拖动事件
|
|
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
// 阻止默认的拖拽行为
|
|
e.preventDefault();
|
|
setIsDragging(true);
|
|
setStartX(e.pageX - thumbnailsRef.current!.offsetLeft);
|
|
setScrollLeft(thumbnailsRef.current!.scrollLeft);
|
|
};
|
|
|
|
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
if (!isDragging) return;
|
|
e.preventDefault();
|
|
const x = e.pageX - thumbnailsRef.current!.offsetLeft;
|
|
const walk = (x - startX) * 2;
|
|
thumbnailsRef.current!.scrollLeft = scrollLeft - walk;
|
|
};
|
|
|
|
const handleMouseUp = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
setIsDragging(false);
|
|
if (!isDragging) return;
|
|
};
|
|
|
|
// 监听阶段变化
|
|
useEffect(() => {
|
|
console.log('taskObject.currentStage_thumbnail-grid', taskObject.currentStage);
|
|
}, [taskObject.currentStage]);
|
|
|
|
// 渲染加载状态
|
|
if (isLoading) {
|
|
return (
|
|
<>
|
|
<Skeleton className="w-[128px] h-[128px] rounded-lg" />
|
|
<Skeleton className="w-[128px] h-[128px] rounded-lg" />
|
|
<Skeleton className="w-[128px] h-[128px] rounded-lg" />
|
|
<Skeleton className="w-[128px] h-[128px] rounded-lg" />
|
|
</>
|
|
);
|
|
}
|
|
|
|
// 粗剪/精剪最终成片阶段不显示缩略图
|
|
if (taskObject.currentStage === 'final_video') {
|
|
return null;
|
|
}
|
|
|
|
// 渲染生成中的缩略图
|
|
const renderGeneratingThumbnail = () => {
|
|
const currentSketch = taskSketch[currentSketchIndex];
|
|
const defaultBgColors = ['RGB(45, 50, 70)', 'RGB(75, 80, 100)', 'RGB(105, 110, 130)'];
|
|
const bgColors = currentSketch?.bg_rgb || defaultBgColors;
|
|
|
|
return (
|
|
<motion.div
|
|
className="relative aspect-video rounded-lg overflow-hidden"
|
|
initial={{ opacity: 0, scale: 0.8 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
transition={{ duration: 0.3 }}
|
|
>
|
|
{/* 动态渐变背景 */}
|
|
<motion.div
|
|
className={`absolute inset-0 bg-gradient-to-r from-[${bgColors[0]}] via-[${bgColors[1]}] to-[${bgColors[2]}]`}
|
|
animate={{
|
|
backgroundPosition: ["0% 50%", "100% 50%", "0% 50%"],
|
|
}}
|
|
transition={{
|
|
duration: 5,
|
|
repeat: Infinity,
|
|
ease: "linear"
|
|
}}
|
|
style={{
|
|
backgroundSize: "200% 200%",
|
|
}}
|
|
/>
|
|
{/* 动态光效 */}
|
|
<motion.div
|
|
className="absolute inset-0 opacity-50"
|
|
style={{
|
|
background: "radial-gradient(circle at center, rgba(255,255,255,0.8) 0%, transparent 50%)",
|
|
}}
|
|
animate={{
|
|
scale: [1, 1.2, 1],
|
|
}}
|
|
transition={{
|
|
duration: 2,
|
|
repeat: Infinity,
|
|
ease: "easeInOut"
|
|
}}
|
|
/>
|
|
<div className="absolute inset-0 flex items-center justify-center">
|
|
<div className="relative">
|
|
<motion.div
|
|
className="absolute -inset-4 bg-gradient-to-r from-white via-sky-200 to-cyan-200 rounded-full opacity-60 blur-xl"
|
|
animate={{
|
|
scale: [1, 1.2, 1],
|
|
rotate: [0, 180, 360],
|
|
}}
|
|
transition={{
|
|
duration: 4,
|
|
repeat: Infinity,
|
|
ease: "linear"
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/60 to-transparent">
|
|
<span className="text-xs text-white/90">Scene {sketchCount + 1}</span>
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
};
|
|
|
|
// 渲染视频阶段的缩略图
|
|
const renderVideoThumbnails = () => (
|
|
taskObject.videos.data.map((video, index) => {
|
|
|
|
return (
|
|
<div
|
|
key={`video-${index}`}
|
|
className={`relative aspect-video rounded-lg overflow-hidden
|
|
${currentSketchIndex === index ? 'ring-2 ring-blue-500 z-10' : 'hover:ring-2 hover:ring-blue-500/50'}`}
|
|
onClick={() => !isDragging && onSketchSelect(index)}
|
|
>
|
|
|
|
{/* 视频层 */}
|
|
<div className="relative w-full h-full transform hover:scale-105 transition-transform duration-500">
|
|
{taskObject.videos.data[index].video_status === 0 && (
|
|
<div className="absolute inset-0 bg-black/10 flex items-center justify-center z-20">
|
|
<div className="text-blue-500 text-xl font-bold flex items-center gap-2">
|
|
<Loader2 className="w-10 h-10 animate-spin" />
|
|
<span>Generating...</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{taskObject.videos.data[index].video_status === 2 && (
|
|
<div className="absolute inset-0 bg-red-500/10 flex items-center justify-center z-20">
|
|
<div className="text-red-500 text-xl font-bold flex items-center gap-2">
|
|
<X className="w-10 h-10" />
|
|
<span>Failed</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{taskObject.videos.data[index].urls ? (
|
|
<video
|
|
className="w-full h-full object-cover"
|
|
src={taskObject.videos.data[index].urls[0]}
|
|
playsInline
|
|
loop
|
|
muted
|
|
/>
|
|
) : (
|
|
<div className="w-full h-full transform hover:scale-105 transition-transform duration-500">
|
|
<img
|
|
className={`w-full h-full object-cover transition-all duration-300 select-none ${
|
|
(!taskObject.shot_sketch.data[index]) ? 'filter blur-sm opacity-60' : ''
|
|
}`}
|
|
src={taskObject.shot_sketch.data[index] ? taskObject.shot_sketch.data[index].url : video.urls[0]}
|
|
alt={`Thumbnail ${index + 1}`}
|
|
draggable="false"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
</div>
|
|
|
|
<div className="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/60 to-transparent z-10">
|
|
<span className="text-xs text-white/90">Scene {index + 1}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
})
|
|
);
|
|
|
|
// 渲染分镜草图阶段的缩略图
|
|
const renderSketchThumbnails = (sketchData: any[]) => (
|
|
<>
|
|
{sketchData.map((sketch, index) => {
|
|
|
|
return (
|
|
<div
|
|
key={`sketch-${index}`}
|
|
className={`relative aspect-video rounded-lg overflow-hidden
|
|
${currentSketchIndex === index ? 'ring-2 ring-blue-500 z-10' : 'hover:ring-2 hover:ring-blue-500/50'}`}
|
|
onClick={() => !isDragging && onSketchSelect(index)}
|
|
>
|
|
|
|
{/* 状态 */}
|
|
{sketch.status === 0 && (
|
|
<div className="absolute inset-0 bg-black/10 flex items-center justify-center">
|
|
<div className="text-blue-500 text-xl font-bold flex items-center gap-2">
|
|
<Loader2 className="w-10 h-10 animate-spin" />
|
|
<span>Generating...</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{sketch.status === 2 && (
|
|
<div className="absolute inset-0 bg-red-500/10 flex items-center justify-center">
|
|
<div className="text-red-500 text-xl font-bold flex items-center gap-2">
|
|
<X className="w-10 h-10" />
|
|
<span>Failed</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{/* 只在生成过程中或没有分镜图片时使用ProgressiveReveal */}
|
|
{(sketch.status === 1) && (
|
|
<div className="w-full h-full transform hover:scale-105 transition-transform duration-500">
|
|
<img
|
|
className="w-full h-full object-cover select-none"
|
|
src={sketch.url}
|
|
alt={`NG ${index + 1}`}
|
|
draggable="false"
|
|
/>
|
|
</div>
|
|
)}
|
|
<div className="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/60 to-transparent">
|
|
<span className="text-xs text-white/90">Scene {index + 1}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
{isGeneratingSketch && sketchCount < totalSketchCount && renderGeneratingThumbnail()}
|
|
</>
|
|
);
|
|
|
|
return (
|
|
<div
|
|
ref={thumbnailsRef}
|
|
tabIndex={0}
|
|
className="w-full grid grid-flow-col auto-cols-[20%] gap-4 overflow-x-auto hide-scrollbar px-1 py-1 cursor-grab active:cursor-grabbing focus:outline-none select-none"
|
|
autoFocus
|
|
onMouseDown={handleMouseDown}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseUp={handleMouseUp}
|
|
onMouseLeave={() => setIsDragging(false)}
|
|
onFocus={() => setIsFocused(true)}
|
|
onBlur={() => setIsFocused(false)}
|
|
>
|
|
{taskObject.currentStage === 'video' && renderVideoThumbnails()}
|
|
{taskObject.currentStage === 'scene' && renderSketchThumbnails(taskObject.scenes.data)}
|
|
{taskObject.currentStage === 'shot_sketch' && renderSketchThumbnails(taskObject.shot_sketch.data)}
|
|
{taskObject.currentStage === 'character' && renderSketchThumbnails(taskObject.scenes.data)}
|
|
</div>
|
|
);
|
|
}
|