video-flow-b/components/pages/work-flow/thumbnail-grid.tsx
2025-09-23 16:51:43 +08:00

373 lines
14 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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, SquareUserRound, MapPinHouse, Clapperboard, Video, RotateCcw, CircleAlert } from 'lucide-react';
import { TaskObject } from '@/api/DTO/movieEdit';
import { getFirstFrame } from '@/utils/tools';
import { AspectRatioValue } from '@/components/ChatInputBox/AspectRatioSelector';
interface ThumbnailGridProps {
isDisabledFocus: boolean;
taskObject: TaskObject;
currentSketchIndex: number;
onSketchSelect: (index: number) => void;
onRetryVideo: (video_id: string) => void;
className: string;
selectedView?: 'final' | 'video' | null;
aspectRatio: AspectRatioValue;
cols: string;
isMobile: boolean;
}
/**
* 视频缩略图网格组件支持hover时播放视频预览
*/
export function ThumbnailGrid({
isDisabledFocus,
taskObject,
currentSketchIndex,
onSketchSelect,
onRetryVideo,
className,
selectedView,
aspectRatio,
cols,
isMobile
}: ThumbnailGridProps) {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
/** 处理鼠标进入缩略图事件 */
const handleMouseEnter = useCallback((index: number) => {
setHoveredIndex(index);
}, []);
/** 处理鼠标离开缩略图事件 */
const handleMouseLeave = useCallback((index: number) => {
setHoveredIndex(null);
}, []);
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 thumbnails = container.children;
if (currentSketchIndex >= 0 && currentSketchIndex < thumbnails.length) {
const thumbnail = thumbnails[currentSketchIndex] as HTMLElement;
const containerLeft = container.getBoundingClientRect().left;
const thumbnailLeft = thumbnail.getBoundingClientRect().left;
const scrollPosition = container.scrollLeft + (thumbnailLeft - containerLeft);
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') {
// 为 roles 和 scenes 数据添加唯一标识前缀,避免重复
const rolesWithPrefix = taskObject.roles.data.map((role, index) => ({
...role,
uniqueId: `role_${index}`
}));
const scenesWithPrefix = taskObject.scenes.data.map((scene, index) => ({
...scene,
uniqueId: `scene_${index}`
}));
return [...rolesWithPrefix, ...scenesWithPrefix];
}
return [];
}, [taskObject.currentStage, taskObject.videos.data, taskObject.roles.data, taskObject.scenes.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 && !isDisabledFocus) {
window.addEventListener('keydown', handleKeyDown);
}
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleKeyDown, isDisabledFocus]);
// 处理鼠标/触摸拖动事件
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]);
// 渲染视频阶段的缩略图
const renderVideoThumbnails = (disabled: boolean = false) => (
taskObject.videos.data.map((video, index) => {
const urls: string = video.urls ? video.urls.join(',') : '';
return (
<div
key={`video-${urls}-${index}`}
className={`relative aspect-auto rounded-lg overflow-hidden
${(currentSketchIndex === index && !disabled && selectedView !== 'final') ? 'ring-2 ring-blue-500 z-10' : 'hover:ring-2 hover:ring-blue-500/50'}
${aspectRatio === 'VIDEO_ASPECT_RATIO_LANDSCAPE' ? `min-w-[${cols}]` : 'min-w-[70px]'}
`}
onClick={() => !isDragging && !disabled && 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" />
</div>
</div>
)}
{taskObject.videos.data[index].video_status === 2 && (
<div className="absolute inset-0 bg-red-500/5 flex items-center justify-center z-20">
<div className="text-2xl mb-4"></div>
</div>
)}
{taskObject.videos.data[index].urls && taskObject.videos.data[index].urls.length > 0 ? (
// <video
// className="w-full h-full object-contain"
// src={taskObject.videos.data[index].urls[0]}
// playsInline
// loop
// muted
// />
<div
className="w-full h-full relative"
onMouseEnter={() => handleMouseEnter(index)}
onMouseLeave={() => handleMouseLeave(index)}
>
<img
className="w-full h-full object-contain"
src={getFirstFrame(taskObject.videos.data[index].urls[0])}
draggable="false"
alt="video thumbnail"
/>
{hoveredIndex === index && (
<video
className="absolute inset-0 w-full h-full object-contain"
src={taskObject.videos.data[index].urls[0]}
autoPlay
muted
playsInline
loop
poster={getFirstFrame(taskObject.videos.data[index].urls[0])}
preload="none"
/>
)}
</div>
) : (
<div className="w-full h-full transform hover:scale-105 transition-transform duration-500">
</div>
)}
</div>
{!isMobile && (
<div className='absolute bottom-0 left-0 right-0 p-2'>
<div className="inline-flex items-center px-2 py-1 rounded-full bg-green-500/20 backdrop-blur-sm">
<Video className="w-3 h-3 text-green-400 mr-1" />
<span className="text-xs text-green-400">{index + 1}</span>
</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.uniqueId || `sketch-${sketch.url}-${index}`}
className={`relative aspect-auto rounded-lg overflow-hidden
${currentSketchIndex === index ? 'ring-2 ring-blue-500 z-10' : 'hover:ring-2 hover:ring-blue-500/50'}
${aspectRatio === 'VIDEO_ASPECT_RATIO_LANDSCAPE' ? `min-w-[${cols}]` : 'min-w-[70px]'}
`}
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" />
</div>
</div>
)}
{sketch.status === 2 && (
<div className="absolute inset-0 bg-red-500/5 flex items-center justify-center">
<div className="text-[#813b9dcc] text-xl font-bold flex items-center gap-2">
<CircleAlert className="w-10 h-10" />
</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-contain select-none"
src={sketch.url}
draggable="false"
alt={sketch.type ? String(sketch.type) : 'sketch'}
/>
</div>
)}
{!isMobile && (
<div className='absolute bottom-0 left-0 right-0 p-2'>
{/* 角色类型 */}
{sketch.type === 'role' && (
<div className="inline-flex items-center px-2 py-1 rounded-full bg-purple-500/20 backdrop-blur-sm">
<SquareUserRound className="w-3 h-3 text-purple-400 mr-1" />
<span className="text-xs text-purple-400">Role</span>
</div>
)}
{/* 场景类型 */}
{sketch.type === 'scene' && (
<div className="inline-flex items-center px-2 py-1 rounded-full bg-purple-500/20 backdrop-blur-sm">
<MapPinHouse className="w-3 h-3 text-purple-400 mr-1" />
<span className="text-xs text-purple-400">Scene</span>
</div>
)}
{/* 分镜类型 */}
{(!sketch.type || sketch.type === 'shot_sketch') && (
<div className="inline-flex items-center px-2 py-1 rounded-full bg-cyan-500/20 backdrop-blur-sm">
<Clapperboard className="w-3 h-3 text-cyan-400 mr-1" />
<span className="text-xs text-cyan-400">{index + 1}</span>
</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">{sketch.type === 'role' ? 'Role' : (sketch.type === 'scene' ? 'Scene' : 'Shot')} {index + 1}</span>
</div> */}
</div>
);
})}
</>
);
return (
<div
ref={thumbnailsRef}
tabIndex={0}
className={`w-full h-full grid grid-flow-col gap-2 overflow-x-auto hide-scrollbar px-1 py-1 cursor-grab active:cursor-grabbing focus:outline-none select-none auto-cols-[${cols}] ${aspectRatio === 'VIDEO_ASPECT_RATIO_LANDSCAPE' ? '' : '!auto-cols-max'}`}
autoFocus
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={() => setIsDragging(false)}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
>
{(taskObject.currentStage === 'video' || taskObject.currentStage === 'final_video') && renderVideoThumbnails()}
{(taskObject.currentStage === 'scene' || taskObject.currentStage === 'character') && renderSketchThumbnails(getCurrentData())}
</div>
);
}