缩略图区域

This commit is contained in:
北枳 2025-10-04 16:33:03 +08:00
parent 1d5a98bb7d
commit de9b28e935
7 changed files with 312 additions and 270 deletions

View File

@ -165,7 +165,7 @@
display: grid;
grid-auto-rows: auto;
justify-content: center;
align-items: center;
align-items: self-start;
}
.videoContainer-qteKNi {
flex: 1;
@ -181,7 +181,7 @@
object-position: center;
background-color: #0003;
border-radius: 8px;
height: 100%;
/* height: 100%; */
}
.container-kIPoeH {
box-sizing: border-box;

View File

@ -448,8 +448,13 @@ Please process this video editing request.`;
ref={containerRef}
>
{isDesktop ? (
<div className={`relative heroVideo-FIzuK1 ${['script'].includes(taskObject.currentStage) ? 'h-[calc(100vh-6rem)] w-[calc((100vh-6rem)/9*16)]' : 'h-[calc(100vh-6rem-200px)] w-[calc((100vh-6rem-200px)/9*16)]'}`} style={{ aspectRatio: "16 / 9" }} key={taskObject.currentStage+'_'+currentSketchIndex}>
<div className={`relative heroVideo-FIzuK1`}
style={{
height: taskObject.final.url ? 'calc(100vh - 8rem)' : 'calc(100vh - 12rem)'
}}
>
<MediaViewer
key={taskObject.currentStage+'_'+currentSketchIndex}
taskObject={taskObject}
scriptData={scriptData}
currentSketchIndex={currentSketchIndex}
@ -471,62 +476,103 @@ Please process this video editing request.`;
onGotoCut={generateEditPlan}
isSmartChatBoxOpen={isSmartChatBoxOpen}
onRetryVideo={(video_id) => handleRetryVideo(video_id)}
aspectRatio={aspectRatio}
/>
{['scene', 'character', 'video', 'final_video'].includes(taskObject.currentStage) && (
<div className={`h-14 absolute bottom-[0.5rem] left-[50%] translate-x-[-50%] z-[21]`}
style={{
maxWidth: 'calc(100% - 5.5rem)'
}}>
<ThumbnailGrid
isDisabledFocus={isEditModalOpen || isPauseWorkFlow || isFocusChatInput}
taskObject={taskObject}
currentSketchIndex={currentSketchIndex}
onSketchSelect={(index) => {
if (index === -1 && taskObject.final.url) {
// 点击最终视频
setSelectedView('final');
setCurrentSketchIndex(0);
} else {
// 点击普通视频
taskObject.final.url && setSelectedView('video');
setCurrentSketchIndex(index);
}
}}
onRetryVideo={handleRetryVideo}
className={isDesktop ? 'auto-cols-[20%]' : (isTablet ? 'auto-cols-[25%]' : 'auto-cols-[33%]')}
cols={isDesktop ? '20%' : isTablet ? '25%' : '33%'}
selectedView={selectedView}
aspectRatio={aspectRatio}
isMobile={isMobile}
/>
</div>
)}
</div>
) : (
<H5MediaViewer
taskObject={taskObject}
scriptData={scriptData}
currentSketchIndex={currentSketchIndex}
selectedView={selectedView}
mode={mode}
setIsPauseWorkFlow={setIsPauseWorkFlow}
setAnyAttribute={setAnyAttribute}
isPauseWorkFlow={isPauseWorkFlow}
applyScript={applyScript}
setCurrentSketchIndex={setCurrentSketchIndex}
onOpenChat={() => setIsSmartChatBoxOpen(true)}
setVideoPreview={(url, id) => {
setPreviewVideoUrl(url);
setPreviewVideoId(id);
}}
showGotoCutButton={showGotoCutButton || editingStatus !== 'idle'}
onGotoCut={generateEditPlan}
isSmartChatBoxOpen={isSmartChatBoxOpen}
onRetryVideo={(video_id) => handleRetryVideo(video_id)}
onSelectView={(view) => setSelectedView(view)}
enableVideoEdit={true}
onVideoEditDescriptionSubmit={handleVideoEditDescriptionSubmit}
projectId={episodeId}
/>
<div className="relative w-full">
<H5MediaViewer
taskObject={taskObject}
scriptData={scriptData}
currentSketchIndex={currentSketchIndex}
selectedView={selectedView}
mode={mode}
setIsPauseWorkFlow={setIsPauseWorkFlow}
setAnyAttribute={setAnyAttribute}
isPauseWorkFlow={isPauseWorkFlow}
applyScript={applyScript}
setCurrentSketchIndex={setCurrentSketchIndex}
onOpenChat={() => setIsSmartChatBoxOpen(true)}
setVideoPreview={(url, id) => {
setPreviewVideoUrl(url);
setPreviewVideoId(id);
}}
showGotoCutButton={showGotoCutButton || editingStatus !== 'idle'}
onGotoCut={generateEditPlan}
isSmartChatBoxOpen={isSmartChatBoxOpen}
onRetryVideo={(video_id) => handleRetryVideo(video_id)}
onSelectView={(view) => setSelectedView(view)}
enableVideoEdit={true}
onVideoEditDescriptionSubmit={handleVideoEditDescriptionSubmit}
projectId={episodeId}
aspectRatio={aspectRatio === 'VIDEO_ASPECT_RATIO_LANDSCAPE' ? '16:9' : '9:16'}
/>
{['scene', 'character', 'video', 'final_video'].includes(taskObject.currentStage) && (
<div className={`h-14 absolute left-[50%] translate-x-[-50%] z-[21] ${isMobile ? '' : 'bottom-[180px]'}`}
style={{
maxWidth: 'calc(100vw - 5.5rem)',
bottom: (aspectRatio === 'VIDEO_ASPECT_RATIO_LANDSCAPE') ? '-2.5rem' : '0.5rem'
}}>
<ThumbnailGrid
isDisabledFocus={isEditModalOpen || isPauseWorkFlow || isFocusChatInput}
taskObject={taskObject}
currentSketchIndex={currentSketchIndex}
onSketchSelect={(index) => {
if (index === -1) {
// 点击最终视频
setSelectedView('final');
setCurrentSketchIndex(0);
} else {
// 点击普通视频
setSelectedView('video');
setCurrentSketchIndex(index);
}
}}
onRetryVideo={handleRetryVideo}
className={isDesktop ? 'auto-cols-[20%]' : (isTablet ? 'auto-cols-[25%]' : 'auto-cols-[33%]')}
cols={isDesktop ? '20%' : isTablet ? '25%' : '33%'}
selectedView={selectedView}
aspectRatio={aspectRatio}
isMobile={isMobile}
/>
</div>
)}
</div>
)}
</div>
{taskObject.currentStage !== 'script' && (
<div className={`h-[123px] ${!isDesktop ? '!w-full' : 'w-[calc((100vh-6rem-200px)/9*16)]'}`}>
<ThumbnailGrid
isDisabledFocus={isEditModalOpen || isPauseWorkFlow || isFocusChatInput}
taskObject={taskObject}
currentSketchIndex={currentSketchIndex}
onSketchSelect={(index) => {
if (index === -1) {
// 点击最终视频
setSelectedView('final');
setCurrentSketchIndex(0);
} else {
// 点击普通视频
setSelectedView('video');
setCurrentSketchIndex(index);
}
}}
onRetryVideo={handleRetryVideo}
className={isDesktop ? 'auto-cols-[20%]' : (isTablet ? 'auto-cols-[25%]' : 'auto-cols-[33%]')}
cols={isDesktop ? '20%' : isTablet ? '25%' : '33%'}
selectedView={selectedView}
aspectRatio={aspectRatio}
isMobile={isMobile}
/>
</div>
)}
</div>
</div>

View File

@ -49,6 +49,8 @@ interface H5MediaViewerProps {
onVideoEditDescriptionSubmit?: (editPoint: any, description: string) => void;
/** 项目ID */
projectId?: string;
/** 视频比例 */
aspectRatio?: string;
}
/**
@ -76,7 +78,8 @@ export function H5MediaViewer({
onSelectView,
enableVideoEdit,
onVideoEditDescriptionSubmit,
projectId
projectId,
aspectRatio
}: H5MediaViewerProps) {
const carouselRef = useRef<CarouselRef>(null);
const videoRefs = useRef<Array<HTMLVideoElement | null>>([]);
@ -85,10 +88,34 @@ export function H5MediaViewer({
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [isCatalogOpen, setIsCatalogOpen] = useState<boolean>(false);
/** 解析形如 "16:9" 的比例字符串 */
const parseAspect = (input?: string): { w: number; h: number } => {
const parts = (typeof input === 'string' ? input.split(':') : []);
const w = Number(parts[0]);
const h = Number(parts[1]);
return {
w: Number.isFinite(w) && w > 0 ? w : 16,
h: Number.isFinite(h) && h > 0 ? h : 9
};
};
/** 视频轮播容器高度:按 aspectRatio 计算(基于视口宽度) */
const videoWrapperHeight = useMemo(() => {
const { w, h } = parseAspect(aspectRatio);
return `calc(100vw * ${h} / ${w})`;
}, [aspectRatio]);
/** 图片轮播容器高度:默认 16:9 */
const imageWrapperHeight = useMemo(() => {
// return 'calc(100vw * 9 / 16)';
const { w, h } = parseAspect(aspectRatio);
return `calc(100vw * ${h} / ${w})`;
}, [aspectRatio]);
// 计算当前阶段类型
const stage = (selectedView === 'final' && taskObject.final?.url)
? 'final_video'
: (!['script', 'character', 'scene'].includes(taskObject.currentStage) ? 'video' : taskObject.currentStage);
: (!['init', 'script', 'character', 'scene'].includes(taskObject.currentStage) ? 'video' : taskObject.currentStage);
// 生成各阶段对应的 slides 数据
const videoUrls = useMemo(() => {
@ -103,16 +130,19 @@ export function H5MediaViewer({
return [];
}, [stage, taskObject.final?.url, taskObject.videos?.data]);
const imageUrls = useMemo(() => {
const imageItems = useMemo(() => {
if (stage === 'scene' || stage === 'character') {
const roles = (taskObject.roles?.data ?? []) as Array<any>;
const scenes = (taskObject.scenes?.data ?? []) as Array<any>;
console.log('h5-media-viewer:stage', stage);
console.log('h5-media-viewer:roles', roles);
console.log('h5-media-viewer:scenes', scenes);
return [...roles, ...scenes].map(item => item?.url).filter(Boolean) as string[];
return [...roles, ...scenes].map(item => ({
url: item?.url as string | undefined,
status: item?.status as number | undefined,
}));
}
return [];
return [] as Array<{ url?: string; status?: number }>;
}, [stage, taskObject.roles?.data, taskObject.scenes?.data]);
// 占位,避免未使用警告
@ -164,7 +194,7 @@ export function H5MediaViewer({
// 渲染视频 slide
const renderVideoSlides = () => (
<div data-alt="carousel-wrapper" className="relative w-full aspect-auto min-h-[200px] overflow-hidden rounded-lg" style={{
height: 'calc(100vh - 20rem)',
height: videoWrapperHeight,
}}>
<Carousel
ref={carouselRef}
@ -190,7 +220,7 @@ export function H5MediaViewer({
ref={(el) => (videoRefs.current[idx] = el)}
className="w-full h-full object-contain [transform:translateZ(0)] [backface-visibility:hidden] [will-change:transform] bg-black"
style={{
maxHeight: 'calc(100vh - 20rem)',
maxHeight: '100%',
}}
src={url}
preload="metadata"
@ -224,7 +254,7 @@ export function H5MediaViewer({
</>
) : (
<div className="w-full aspect-auto min-h-[200px] flex items-center justify-center bg-black/10 relative" data-alt="video-status" style={{
height: 'calc(100vh - 20rem)',
height: videoWrapperHeight,
}}>
{status === 0 && (
<span className="text-blue-500 text-base">Generating...</span>
@ -250,11 +280,11 @@ export function H5MediaViewer({
// 渲染图片 slide
const renderImageSlides = () => (
<div data-alt="carousel-wrapper" className="relative w-full aspect-auto min-h-[200px] overflow-hidden rounded-lg" style={{
height: 'calc(100vh - 20rem)',
height: imageWrapperHeight,
}}>
<Carousel
ref={carouselRef}
key={`h5-carousel-image-${stage}-${imageUrls.length}`}
key={`h5-carousel-image-${stage}-${imageItems.length}`}
arrows
dots={false}
infinite={false}
@ -263,13 +293,34 @@ export function H5MediaViewer({
slidesToShow={1}
adaptiveHeight
>
{imageUrls.map((url, idx) => (
<div key={`h5-image-${idx}`} data-alt="image-slide" className="relative w-full h-full flex justify-center">
<img src={url} alt="scene" className="w-full h-full object-contain" style={{
maxHeight: 'calc(100vh - 20rem)',
}} />
</div>
))}
{imageItems.map((item, idx) => {
const status = item?.status;
const url = item?.url;
const showImage = status === 1 && typeof url === 'string' && url.length > 0;
return (
<div key={`h5-image-${idx}`} data-alt="image-slide" className="relative w-full h-full flex justify-center">
{showImage ? (
<img src={url} alt="scene" className="w-full h-full object-contain" style={{
maxHeight: '100%',
}} />
) : (
<div className="w-full aspect-auto min-h-[200px] flex items-center justify-center bg-black/10 relative" data-alt="image-status" style={{
height: imageWrapperHeight,
}}>
{status === 0 && (
<span className="text-blue-500 text-base">Generating...</span>
)}
{status === 2 && (
<div className="flex flex-col items-center justify-center gap-3">
<div className="text-4xl"></div>
<span className="text-red-500 text-base">Generate failed</span>
</div>
)}
</div>
)}
</div>
);
})}
</Carousel>
</div>
);
@ -285,7 +336,10 @@ export function H5MediaViewer({
}
};
return (
<div data-alt="script-content" className="w-full h-full">
<div data-alt="script-content" className="w-full overflow-auto"
style={{
height: 'calc(100vh - 10rem)'
}}>
{scriptData ? (
<>
<ScriptRenderer
@ -370,13 +424,16 @@ export function H5MediaViewer({
// 其他阶段:使用 Carousel
return (
<div ref={rootRef} data-alt="h5-media-viewer" className={`w-[100vw] relative ${stage === 'final_video' ? '' : 'px-4'}`}>
<div ref={rootRef} data-alt="h5-media-viewer" className={`relative`}
style={{
width: 'calc(100vw - 2rem)'
}}>
{stage === 'final_video' && videoUrls.length > 0 && renderVideoSlides()}
{stage === 'video' && videoUrls.length > 0 && renderVideoSlides()}
{(stage === 'scene' || stage === 'character') && imageUrls.length > 0 && renderImageSlides()}
{(stage === 'scene' || stage === 'character') && imageItems.length > 0 && renderImageSlides()}
{/* 全局固定操作区(右下角)视频暂停时展示 */}
{(stage === 'video' || stage === 'final_video') && !isPlaying && (
<div data-alt="global-video-actions" className="absolute bottom-0 right-4 z-[60] flex flex-col items-center gap-2">
<div data-alt="global-video-actions" className="absolute top-0 right-4 z-[60] flex flex-col items-center gap-2">
{stage === 'video' && (
<>
<GlassIconButton
@ -471,10 +528,13 @@ export function H5MediaViewer({
)}
<style jsx global>{`
[data-alt='carousel-wrapper'] .slick-slide { display: flex !important;justify-content: center; }
.slick-slider { height: 100% !important;display: flex !important; }
.slick-slider { height: 100% !important; }
.ant-carousel { height: 100% !important; }
.slick-list { width: 100%;height: 100% !important;max-height: calc(100vh - 20rem); }
.slick-list { width: 100%;height: 100% !important;max-height: 100%; }
.slick-track { display: flex !important; align-items: center;height: 100% !important; }
[data-alt='carousel-wrapper'] .slick-arrow { z-index: 70 !important; }
[data-alt='carousel-wrapper'] .slick-prev { left: 8px; }
[data-alt='carousel-wrapper'] .slick-next { right: 8px; }
`}</style>
</div>
);

View File

@ -15,6 +15,8 @@ import { downloadVideo, downloadAllVideos, getFirstFrame } from '@/utils/tools';
import { VideoEditOverlay } from './video-edit/VideoEditOverlay';
import { EditPoint as EditPointType } from './video-edit/types';
import { isVideoModificationEnabled } from '@/lib/server-config';
import error_image from '@/public/assets/error.webp';
import { AspectRatioValue } from '@/components/ChatInputBox/AspectRatioSelector';
interface MediaViewerProps {
taskObject: TaskObject;
@ -38,6 +40,7 @@ interface MediaViewerProps {
enableVideoEdit?: boolean;
onVideoEditDescriptionSubmit?: (editPoint: EditPointType, description: string) => void;
projectId?: string;
aspectRatio: AspectRatioValue;
}
export const MediaViewer = React.memo(function MediaViewer({
@ -61,7 +64,8 @@ export const MediaViewer = React.memo(function MediaViewer({
onRetryVideo,
enableVideoEdit = true,
onVideoEditDescriptionSubmit,
projectId
projectId,
aspectRatio
}: MediaViewerProps) {
const mainVideoRef = useRef<HTMLVideoElement>(null);
const finalVideoRef = useRef<HTMLVideoElement>(null);
@ -212,6 +216,9 @@ export const MediaViewer = React.memo(function MediaViewer({
<video
ref={finalVideoRef}
className="w-full h-full object-contain rounded-lg"
style={{
aspectRatio: aspectRatio === 'VIDEO_ASPECT_RATIO_LANDSCAPE' ? "16 / 9" : "9 / 16"
}}
src={taskObject.final.url}
autoPlay={isFinalVideoPlaying}
loop
@ -396,47 +403,14 @@ export const MediaViewer = React.memo(function MediaViewer({
playing: boolean
) => (
<div
className="absolute left-0 right-0 bottom-2 z-[21] px-6"
className="absolute left-0 right-0 bottom-4 z-[21] px-2"
data-alt={isFinal ? 'final-controls' : 'video-controls'}
>
<div className="flex items-center gap-3">
<div className="flex items-center justify-between">
{/* 播放/暂停 */}
<GlassIconButton icon={playing ? Pause : Play} onClick={onPlayToggle} size="sm" />
{/* 静音,仅图标 */}
<GlassIconButton icon={isMuted ? VolumeX : Volume2} onClick={toggleMute} size="sm" />
{/* 进度条 */}
<div className="flex-1 flex items-center">
<input
type="range"
min="0"
max="100"
step="0.1"
value={progressPercent}
onChange={(e) => seekTo(parseFloat(e.target.value))}
className="w-full h-1 bg-white/20 rounded-lg appearance-none cursor-pointer
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white
[&::-webkit-slider-thumb]:cursor-pointer
[&::-moz-range-thumb]:w-3 [&::-moz-range-thumb]:h-3 [&::-moz-range-thumb]:rounded-full
[&::-moz-range-thumb]:bg-white [&::-moz-range-thumb]:cursor-pointer [&::-moz-range-thumb]:border-none"
style={{
background: `linear-gradient(to right, white 0%, white ${progressPercent}%, rgba(255,255,255,0.2) ${progressPercent}%, rgba(255,255,255,0.2) 100%)`
}}
/>
</div>
{/* 剩余时间 */}
<div className="text-white/80 text-sm w-14 text-right select-none" data-alt="time-remaining">
{formatRemaining(duration, currentTime)}
</div>
{/* 画中画 */}
<GlassIconButton icon={PictureInPicture2} onClick={requestPip} size="sm" />
{/* 全屏 */}
<GlassIconButton icon={isFullscreen ? Minimize : Maximize} onClick={toggleFullscreen} size="sm" />
<GlassIconButton className="group-hover:block hidden animate-in duration-300" icon={isFullscreen ? Minimize : Maximize} onClick={toggleFullscreen} size="sm" />
</div>
</div>
);
@ -456,11 +430,14 @@ export const MediaViewer = React.memo(function MediaViewer({
<motion.div
className="absolute inset-0 overflow-hidden"
initial={{ filter: "blur(0px)", scale: 1, opacity: 1 }}
animate={{ filter: "blur(20px)", scale: 1.1, opacity: 0.5 }}
animate={{ filter: "blur(20px)", scale: 1, opacity: 0.5 }}
transition={{ duration: 0.8, ease: "easeInOut" }}
>
<video
className="w-full h-full rounded-lg object-contain object-center"
style={{
aspectRatio: aspectRatio === 'VIDEO_ASPECT_RATIO_LANDSCAPE' ? "16 / 9" : "9 / 16"
}}
src={taskObject.final.url}
loop
playsInline
@ -484,8 +461,9 @@ export const MediaViewer = React.memo(function MediaViewer({
</motion.div>
{/* 编辑和剪辑按钮 */}
<div className="absolute top-4 z-[21] flex items-center gap-2 transition-right duration-100" style={{
right: toosBtnRight
<div className="absolute top-2 right-2 z-[21] flex items-center gap-2 transition-right duration-100"
style={{
right: aspectRatio === 'VIDEO_ASPECT_RATIO_LANDSCAPE' ? toosBtnRight : ''
}}>
<Tooltip placement="top" title='Edit'>
<GlassIconButton
@ -535,6 +513,9 @@ export const MediaViewer = React.memo(function MediaViewer({
className="relative w-full h-full rounded-lg group"
key={`render-video-${urls}`}
ref={videoContentRef}
style={{
width: taskObject.videos.data[currentSketchIndex].video_status !== 1 ? 'calc(100vw - 8rem)' : '100%'
}}
>
{/* 背景模糊的图片 */}
{taskObject.videos.data[currentSketchIndex].video_status !== 1 && (
@ -580,6 +561,9 @@ export const MediaViewer = React.memo(function MediaViewer({
ref={mainVideoRef}
key={taskObject.videos.data[currentSketchIndex].urls[0]}
className="w-full h-full rounded-lg object-contain object-center relative z-10"
style={{
aspectRatio: aspectRatio === 'VIDEO_ASPECT_RATIO_LANDSCAPE' ? "16 / 9" : "9 / 16"
}}
src={taskObject.videos.data[currentSketchIndex].urls[0]}
poster={getFirstFrame(taskObject.videos.data[currentSketchIndex].urls[0])}
preload="none"
@ -612,8 +596,8 @@ export const MediaViewer = React.memo(function MediaViewer({
</motion.div>
{/* 跳转剪辑按钮 */}
<div className="absolute top-4 right-4 z-[21] flex items-center gap-2 transition-right duration-100" style={{
right: toosBtnRight
<div className="absolute top-2 right-2 z-[21] flex items-center gap-2 transition-right duration-100" style={{
right: aspectRatio === 'VIDEO_ASPECT_RATIO_LANDSCAPE' ? toosBtnRight : ''
}}>
{/* 视频编辑模式切换按钮 - 通过服务器配置控制显示 */}
{enableVideoEdit && showVideoModification && (
@ -702,8 +686,11 @@ export const MediaViewer = React.memo(function MediaViewer({
return (
<div
className="relative w-full h-full rounded-lg group"
className="relative w-full h-full rounded-lg group overflow-hidden"
key={`render-sketch-${currentSketch.url}`}
style={{
width: currentSketch.status === 1 ? '100%' : 'calc(100vw - 8rem)'
}}
>
{/* 状态 */}
{currentSketch.status === 0 && (
@ -715,8 +702,8 @@ export const MediaViewer = React.memo(function MediaViewer({
</div>
)}
{currentSketch.status === 2 && (
<div className="absolute inset-0 bg-red-500/5 flex items-center justify-center">
<div className="text-2xl mb-4"></div>
<div className="absolute inset-0 bg-[#fcb0ba1a] flex items-center justify-center">
<img src={error_image.src} alt="error" className="w-12 h-12" />
</div>
)}
{/* 只在生成过程中或没有分镜图片时使用ProgressiveReveal */}
@ -785,7 +772,10 @@ export const MediaViewer = React.memo(function MediaViewer({
// 渲染剧本
const renderScriptContent = () => {
return (
<div className="relative w-full h-full rounded-lg overflow-hidden p-2">
<div className="relative h-full rounded-lg overflow-hidden p-2"
style={{
width: 'calc(100vw - 8rem)'
}}>
{scriptData ? (
<ScriptRenderer
data={scriptData}

View File

@ -220,23 +220,6 @@ export function TaskInfo({
) : currentLoadingText}
</div>
{/* 主题 彩色标签tags */}
<div className="flex items-center justify-center gap-2">
{taskObject?.tags?.map((tag: string) => (
<div
key={tag}
data-alt="tag-item"
className="flex items-center gap-2 text-sm text-[#ececec] rounded-full px-3 py-1.5 bg-white/10 backdrop-blur-sm shadow-[0_4px_12px_rgba(0,0,0,0.2)]"
>
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: tagColors[tag] }}
/>
{tag}
</div>
))}
</div>
<ScriptModal
isOpen={isScriptModalOpen}
onClose={() => {

View File

@ -1,13 +1,11 @@
'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, Film } from 'lucide-react';
import { CircleAlert, Film } from 'lucide-react';
import { TaskObject } from '@/api/DTO/movieEdit';
import { getFirstFrame } from '@/utils/tools';
import { AspectRatioValue } from '@/components/ChatInputBox/AspectRatioSelector';
import error_image from '@/public/assets/error.webp';
interface ThumbnailGridProps {
isDisabledFocus: boolean;
@ -93,37 +91,43 @@ export function ThumbnailGrid({
return [];
}, [taskObject.currentStage, taskObject.videos.data, taskObject.roles.data, taskObject.scenes.data]);
// 使用 useRef 存储前一次的数据,避免触发重渲染
const prevDataRef = useRef<any[]>([]);
/** Store previous status snapshot for change detection */
const prevStatusRef = useRef<Array<number | undefined>>([]);
useEffect(() => {
const currentData = getCurrentData();
if (currentData && currentData.length > 0) {
const currentDataStr = JSON.stringify(currentData);
const prevDataStr = JSON.stringify(prevDataRef.current);
if (!currentData || currentData.length === 0) return;
// 只有当数据真正发生变化时才进行处理
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]);
});
// 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
));
console.log('changedIndex_thumbnail-grid', changedIndex, 'currentData:', currentData, 'prevData:', prevDataRef.current);
const prevStatuses = prevStatusRef.current;
// 如果找到变化的项,自动选择该项
if (changedIndex !== -1) {
onSketchSelect(changedIndex);
}
// 更新前一次的数据快照
prevDataRef.current = JSON.parse(JSON.stringify(currentData));
// 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;
}
}
}, [taskObject, getCurrentData, onSketchSelect]);
console.log('changedIndex_thumbnail-grid', changedIndex);
if (changedIndex !== -1) {
onSketchSelect(changedIndex);
}
// Update snapshot
prevStatusRef.current = currentStatuses.slice();
}, [taskObject, getCurrentData]);
// 处理键盘左右键事件
const handleKeyDown = useCallback((e: KeyboardEvent) => {
@ -192,49 +196,45 @@ export function ThumbnailGrid({
{taskObject?.final?.url && (
<div
key="video-final"
className={`relative aspect-auto rounded-lg overflow-hidden
${selectedView === 'final' ? 'ring-2 ring-amber-500 z-10' : 'hover:ring-2 hover:ring-amber-500/50'}
${aspectRatio === 'VIDEO_ASPECT_RATIO_LANDSCAPE' ? `min-w-[${cols}]` : 'min-w-[70px]'}
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="relative w-full h-full transform hover:scale-105 transition-transform duration-500">
<div
className="w-full h-full relative"
onMouseEnter={() => handleMouseEnter(-1)}
onMouseLeave={() => handleMouseLeave(-1)}
>
<img
className="w-full h-full object-contain"
src={getFirstFrame(taskObject.final.url)}
draggable="false"
alt="final video thumbnail"
/>
{hoveredIndex === -1 && (
<video
className="absolute inset-0 w-full h-full object-contain"
src={taskObject.final.url}
autoPlay
muted
playsInline
loop
poster={getFirstFrame(taskObject.final.url)}
preload="none"
<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-0 left-0 z-20'>
<div className="relative w-12 h-12">
{/* 三角形背景 */}
<div className="absolute top-0 left-0 w-0 h-0 border-t-[3rem] border-t-amber-400/60 border-r-[3rem] border-r-transparent" />
{/* 图标 */}
<div className="absolute top-1 left-1 flex flex-col items-center gap-0.5">
<Film className="w-3.5 h-3.5 text-white" />
</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>
@ -247,25 +247,27 @@ export function ThumbnailGrid({
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]'}
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-500">
<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 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 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/5 flex items-center justify-center z-20">
<div className="text-2xl mb-4"></div>
<div className="absolute inset-0 bg-red-500/10 flex items-center justify-center z-20">
<img src={error_image.src} alt="error" className="w-6 h-6" />
</div>
)}
@ -283,14 +285,14 @@ export function ThumbnailGrid({
onMouseLeave={() => handleMouseLeave(index)}
>
<img
className="w-full h-full object-contain"
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-contain"
className="absolute inset-0 w-full h-full object-cover"
src={taskObject.videos.data[index].urls[0]}
autoPlay
muted
@ -302,27 +304,10 @@ export function ThumbnailGrid({
)}
</div>
) : (
<div className="w-full h-full transform hover:scale-105 transition-transform duration-500">
</div>
<div className="w-full h-full" />
)}
</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>
);
})}
@ -337,63 +322,40 @@ export function ThumbnailGrid({
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]'}
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 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 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/5 flex items-center justify-center z-20">
<div className="text-2xl mb-4"></div>
<div className="absolute inset-0 bg-red-500/10 flex items-center justify-center z-20">
<img src={error_image.src} alt="error" className="w-6 h-6" />
</div>
)}
{/* 只在生成过程中或没有分镜图片时使用ProgressiveReveal */}
{(sketch.status === 1) && (
<div className="w-full h-full transform hover:scale-105 transition-transform duration-500">
<div className="w-full h-full transform hover:scale-105 transition-transform duration-300">
<img
className="w-full h-full object-contain select-none"
className="w-full h-full object-cover 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">
@ -409,7 +371,8 @@ export function ThumbnailGrid({
<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-min'}`}
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}

BIN
public/assets/error.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB