forked from 77media/video-flow
391 lines
15 KiB
TypeScript
391 lines
15 KiB
TypeScript
'use client';
|
||
|
||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||
import { CircleAlert, Film } 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 containerRect = container.getBoundingClientRect();
|
||
const thumbnailRect = thumbnail.getBoundingClientRect();
|
||
|
||
// 计算滚动位置:将缩略图居中显示
|
||
const containerCenter = containerRect.width / 2;
|
||
const thumbnailCenter = thumbnailRect.width / 2;
|
||
const scrollPosition = container.scrollLeft + (thumbnailRect.left - containerRect.left) - containerCenter + thumbnailCenter;
|
||
|
||
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]);
|
||
|
||
/** Store previous status snapshot for change detection */
|
||
const prevStatusRef = useRef<Array<number | undefined>>([]);
|
||
|
||
useEffect(() => {
|
||
const currentData = getCurrentData();
|
||
if (!currentData || currentData.length === 0) return;
|
||
|
||
// Extract status fields only to detect meaningful changes
|
||
const currentStatuses: Array<number | undefined> = currentData.map((item: any) => (
|
||
taskObject.currentStage === 'video' ? item?.video_status : item?.status
|
||
));
|
||
|
||
const prevStatuses = prevStatusRef.current;
|
||
|
||
// Find first changed or newly added index
|
||
let changedIndex = -1;
|
||
for (let i = 0; i < currentStatuses.length; i += 1) {
|
||
if (i >= prevStatuses.length) {
|
||
changedIndex = i; // new item
|
||
break;
|
||
}
|
||
if (currentStatuses[i] !== prevStatuses[i]) {
|
||
changedIndex = i; // status changed
|
||
break;
|
||
}
|
||
}
|
||
|
||
|
||
|
||
if (changedIndex !== -1) {
|
||
onSketchSelect(changedIndex);
|
||
}
|
||
|
||
// Update snapshot
|
||
prevStatusRef.current = currentStatuses.slice();
|
||
}, [taskObject, getCurrentData]);
|
||
|
||
// 处理键盘左右键事件
|
||
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?.final?.url && (
|
||
<div
|
||
key="video-final"
|
||
data-alt="final-thumbnail"
|
||
className={`relative w-8 h-8 shrink-0 hover:brightness-50 rounded-full duration-300 transition-all ring-offset-surface-tertiary ring-white size-8
|
||
${selectedView === 'final' ? '!w-16 !h-10 ring-2 ring-white ring-offset-2 ring-offset-neutral-900 shadow-[0_0_0_3px_rgba(255,255,255,0.6)] z-10' : 'ring-1 ring-white/20 hover:ring-2 hover:ring-white/60'}
|
||
`}
|
||
onClick={() => !isDragging && !disabled && onSketchSelect(-1)}
|
||
>
|
||
<div className="rounded-full overflow-hidden w-full h-full">
|
||
{/* 视频层 */}
|
||
<div className="relative w-full h-full transform hover:scale-105 transition-transform duration-300">
|
||
<div
|
||
className="w-full h-full relative"
|
||
onMouseEnter={() => handleMouseEnter(-1)}
|
||
onMouseLeave={() => handleMouseLeave(-1)}
|
||
>
|
||
<img
|
||
className="w-full h-full object-cover"
|
||
src={getFirstFrame(taskObject.final.url)}
|
||
draggable="false"
|
||
alt="final video thumbnail"
|
||
/>
|
||
{hoveredIndex === -1 && (
|
||
<video
|
||
className="absolute inset-0 w-full h-full object-cover"
|
||
src={taskObject.final.url}
|
||
autoPlay
|
||
muted
|
||
playsInline
|
||
loop
|
||
poster={getFirstFrame(taskObject.final.url)}
|
||
preload="none"
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{/* 最终视频徽标 */}
|
||
<div className="absolute -top-1 -left-1 z-20">
|
||
<div className="w-4 h-4 rounded-full bg-amber-500/60 flex items-center justify-center">
|
||
<Film className="w-2.5 h-2.5 text-white" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 普通视频缩略图 */}
|
||
{taskObject.videos.data.map((video, index) => {
|
||
const urls: string = video.urls ? video.urls.join(',') : '';
|
||
|
||
return (
|
||
<div
|
||
key={`video-${urls}-${index}`}
|
||
data-alt={`video-thumbnail-${index+1}`}
|
||
className={`relative overflow-hidden w-8 h-8 shrink-0 rounded-full duration-300 transition-all ring-offset-surface-tertiary ring-white size-8
|
||
${(currentSketchIndex === index && !disabled && selectedView !== 'final') ? '!w-16 !h-10 ring-2 ring-white ring-offset-2 ring-offset-neutral-900 shadow-[0_0_0_3px_rgba(255,255,255,0.6)] z-10' : 'ring-1 ring-white/20 hover:ring-2 hover:ring-white/60'}
|
||
`}
|
||
onClick={() => !isDragging && !disabled && onSketchSelect(index)}
|
||
>
|
||
|
||
{/* 视频层 */}
|
||
<div className="relative w-full h-full transform hover:scale-105 transition-transform duration-300">
|
||
{taskObject.videos.data[index].video_status === 0 && (
|
||
<div className="absolute inset-0 flex items-center justify-center z-20">
|
||
<div className="relative size-12">
|
||
<span className="absolute inset-0 rounded-full border-2 border-white/40 animate-ping" />
|
||
<span className="absolute inset-2 rounded-full border-2 border-white/20 animate-ping [animation-delay:150ms]" />
|
||
<span className="absolute inset-0 rounded-full bg-white/5" />
|
||
</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">
|
||
<i className="iconfont icon-shipindiushibaojing text-[#773f8ecc] !text-lg" />
|
||
</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-cover"
|
||
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-cover"
|
||
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" />
|
||
)}
|
||
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</>
|
||
);
|
||
|
||
// 渲染分镜草图阶段的缩略图
|
||
const renderSketchThumbnails = (sketchData: any[]) => (
|
||
<>
|
||
{sketchData.map((sketch, index) => {
|
||
|
||
return (
|
||
<div
|
||
key={sketch.uniqueId || `sketch-${sketch.url}-${index}`}
|
||
data-alt={`sketch-thumbnail-${index+1}`}
|
||
className={`relative overflow-hidden w-8 h-8 shrink-0 hover:brightness-50 rounded-full duration-300 transition-all ring-offset-surface-tertiary ring-white size-8
|
||
${currentSketchIndex === index ? '!w-16 !h-10 ring-2 ring-white ring-offset-2 ring-offset-neutral-900 shadow-[0_0_0_3px_rgba(255,255,255,0.6)] z-10' : 'ring-1 ring-white/20 hover:ring-2 hover:ring-white/60'}
|
||
`}
|
||
onClick={() => !isDragging && onSketchSelect(index)}
|
||
>
|
||
|
||
{/* 状态 */}
|
||
{sketch.status === 0 && (
|
||
<div className="absolute inset-0 flex items-center justify-center">
|
||
<div className="relative size-12">
|
||
<span className="absolute inset-0 rounded-full border-2 border-white/40 animate-ping" />
|
||
<span className="absolute inset-2 rounded-full border-2 border-white/20 animate-ping [animation-delay:150ms]" />
|
||
<span className="absolute inset-0 rounded-full bg-white/5" />
|
||
</div>
|
||
</div>
|
||
)}
|
||
{sketch.status === 2 && (
|
||
<div className="absolute inset-0 bg-red-500/10 flex items-center justify-center z-20">
|
||
<i className="iconfont icon-tudiushi text-[#773f8ecc] !text-lg" />
|
||
</div>
|
||
)}
|
||
{/* 只在生成过程中或没有分镜图片时使用ProgressiveReveal */}
|
||
{(sketch.status === 1) && (
|
||
<div className="w-full h-full transform hover:scale-105 transition-transform duration-300">
|
||
<img
|
||
className="w-full h-full object-cover select-none"
|
||
src={sketch.url}
|
||
draggable="false"
|
||
alt={sketch.type ? String(sketch.type) : 'sketch'}
|
||
/>
|
||
</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}
|
||
data-alt="thumbnail-strip"
|
||
className={`w-full h-full grid grid-flow-col items-center gap-2 px-3 overflow-x-auto hide-scrollbar cursor-grab active:cursor-grabbing focus:outline-none select-none rounded-full ring-1 ring-white/10 shadow-inner backdrop-blur-md bg-white/10 auto-cols-[${cols}] !auto-cols-min border border-white/20`}
|
||
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>
|
||
);
|
||
}
|