2025-10-14 21:48:00 +08:00

391 lines
15 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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 { 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={taskObject.final.snapshot_url || 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={taskObject.final.snapshot_url || 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={taskObject.videos.data[index].snapshot_url || 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={taskObject.videos.data[index].snapshot_url || 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>
);
}