forked from 77media/video-flow
846 lines
31 KiB
TypeScript
846 lines
31 KiB
TypeScript
'use client';
|
||
|
||
import React, { useRef, useEffect, useState, SetStateAction, useMemo } from 'react';
|
||
import { motion, AnimatePresence } from 'framer-motion';
|
||
import { Edit3, Play, Pause, Volume2, VolumeX, Maximize, Minimize, Loader2, X, Scissors, RotateCcw, MessageCircleMore, Download, ArrowDownWideNarrow, PictureInPicture2, PenTool } from 'lucide-react';
|
||
import { ProgressiveReveal, presets } from '@/components/ui/progressive-reveal';
|
||
import { GlassIconButton } from '@/components/ui/glass-icon-button';
|
||
import { ScriptRenderer } from '@/components/script-renderer/ScriptRenderer';
|
||
import { mockScriptData } from '@/components/script-renderer/mock';
|
||
import { Skeleton } from '@/components/ui/skeleton';
|
||
import ScriptLoading from './script-loading';
|
||
import { TaskObject } from '@/api/DTO/movieEdit';
|
||
import { Button, Tooltip } from 'antd';
|
||
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;
|
||
scriptData: any;
|
||
currentSketchIndex: number;
|
||
isVideoPlaying: boolean;
|
||
selectedView?: 'final' | 'video' | null;
|
||
onEditModalOpen: (tab: string) => void;
|
||
onToggleVideoPlay: () => void;
|
||
setIsPauseWorkFlow: (isPause: boolean) => void;
|
||
setAnyAttribute: any;
|
||
isPauseWorkFlow: boolean;
|
||
applyScript: any;
|
||
mode: string;
|
||
onOpenChat?: () => void;
|
||
setVideoPreview?: (url: string, id: string) => void;
|
||
showGotoCutButton?: boolean;
|
||
onGotoCut: () => void;
|
||
isSmartChatBoxOpen: boolean;
|
||
onRetryVideo?: (video_id: string) => void;
|
||
enableVideoEdit?: boolean;
|
||
onVideoEditDescriptionSubmit?: (editPoint: EditPointType, description: string) => void;
|
||
projectId?: string;
|
||
aspectRatio: string;
|
||
placeholderWidth: string;
|
||
}
|
||
|
||
export const MediaViewer = React.memo(function MediaViewer({
|
||
taskObject,
|
||
scriptData,
|
||
currentSketchIndex,
|
||
isVideoPlaying,
|
||
selectedView,
|
||
onEditModalOpen,
|
||
onToggleVideoPlay,
|
||
setIsPauseWorkFlow,
|
||
setAnyAttribute,
|
||
isPauseWorkFlow,
|
||
applyScript,
|
||
mode,
|
||
onOpenChat,
|
||
setVideoPreview,
|
||
showGotoCutButton,
|
||
onGotoCut,
|
||
isSmartChatBoxOpen,
|
||
onRetryVideo,
|
||
enableVideoEdit = true,
|
||
onVideoEditDescriptionSubmit,
|
||
projectId,
|
||
aspectRatio,
|
||
placeholderWidth
|
||
}: MediaViewerProps) {
|
||
const mainVideoRef = useRef<HTMLVideoElement>(null);
|
||
const finalVideoRef = useRef<HTMLVideoElement>(null);
|
||
const videoContentRef = useRef<HTMLDivElement>(null);
|
||
// 音量控制状态
|
||
const [isMuted, setIsMuted] = useState(false);
|
||
const [volume, setVolume] = useState(0.8);
|
||
const [duration, setDuration] = useState(0);
|
||
const [currentTime, setCurrentTime] = useState(0);
|
||
|
||
// 最终视频控制状态
|
||
const [isFinalVideoPlaying, setIsFinalVideoPlaying] = useState(true);
|
||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||
const [finalVideoReady, setFinalVideoReady] = useState(false);
|
||
const [userHasInteracted, setUserHasInteracted] = useState(false);
|
||
const [toosBtnRight, setToodsBtnRight] = useState('1rem');
|
||
const [isLoadingDownloadBtn, setIsLoadingDownloadBtn] = useState(false);
|
||
const [isLoadingDownloadAllVideosBtn, setIsLoadingDownloadAllVideosBtn] = useState(false);
|
||
const [isVideoEditMode, setIsVideoEditMode] = useState(false);
|
||
// 控制钢笔图标显示的状态 - 参考谷歌登录按钮的实现
|
||
const [showVideoModification, setShowVideoModification] = useState(false);
|
||
|
||
useEffect(() => {
|
||
if (isSmartChatBoxOpen) {
|
||
const videoContentWidth = videoContentRef.current?.clientWidth ?? 0;
|
||
const right = (window.innerWidth * 0.25) - ((window.innerWidth - videoContentWidth) / 2) + 32;
|
||
setToodsBtnRight(right + 'px');
|
||
} else {
|
||
setToodsBtnRight('1rem');
|
||
}
|
||
}, [isSmartChatBoxOpen])
|
||
|
||
// 检查视频修改功能是否启用 - 参考谷歌登录按钮的实现
|
||
useEffect(() => {
|
||
const checkVideoModificationStatus = async () => {
|
||
try {
|
||
console.log('🔍 MediaViewer:开始检查视频修改功能状态...');
|
||
const enabled = await isVideoModificationEnabled();
|
||
console.log('📋 MediaViewer:视频修改功能启用状态:', enabled);
|
||
setShowVideoModification(enabled);
|
||
console.log('📋 MediaViewer:设置showVideoModification状态为:', enabled);
|
||
} catch (error) {
|
||
console.error("❌ MediaViewer:Failed to check video modification status:", error);
|
||
setShowVideoModification(false); // 出错时默认不显示
|
||
}
|
||
};
|
||
|
||
checkVideoModificationStatus();
|
||
}, []); // 只在组件挂载时执行一次
|
||
|
||
// 调试:监控钢笔图标显示状态
|
||
useEffect(() => {
|
||
console.log('🔧 MediaViewer状态更新:', {
|
||
enableVideoEdit,
|
||
showVideoModification,
|
||
shouldShowPenIcon: enableVideoEdit && showVideoModification
|
||
});
|
||
}, [enableVideoEdit, showVideoModification]);
|
||
|
||
// 音量控制函数
|
||
const toggleMute = () => {
|
||
setUserHasInteracted(true);
|
||
setIsMuted(!isMuted);
|
||
if (mainVideoRef.current) {
|
||
mainVideoRef.current.muted = !isMuted;
|
||
}
|
||
if (finalVideoRef.current) {
|
||
finalVideoRef.current.muted = !isMuted;
|
||
}
|
||
};
|
||
|
||
const handleVolumeChange = (newVolume: number) => {
|
||
setUserHasInteracted(true);
|
||
setVolume(newVolume);
|
||
if (mainVideoRef.current) {
|
||
mainVideoRef.current.volume = newVolume;
|
||
}
|
||
if (finalVideoRef.current) {
|
||
finalVideoRef.current.volume = newVolume;
|
||
}
|
||
};
|
||
|
||
// 应用音量设置到视频元素
|
||
const applyVolumeSettings = (videoElement: HTMLVideoElement) => {
|
||
if (videoElement) {
|
||
videoElement.volume = volume;
|
||
videoElement.muted = isMuted;
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (finalVideoRef.current && finalVideoReady) {
|
||
if (isFinalVideoPlaying) {
|
||
finalVideoRef.current.play().catch(error => {
|
||
console.log('最终视频自动播放被阻止:', error);
|
||
// 如果自动播放被阻止,将状态设置为暂停
|
||
setIsFinalVideoPlaying(false);
|
||
});
|
||
} else {
|
||
finalVideoRef.current.pause();
|
||
}
|
||
}
|
||
}, [isFinalVideoPlaying, finalVideoReady]);
|
||
|
||
// 最终视频播放控制
|
||
const toggleFinalVideoPlay = () => {
|
||
setUserHasInteracted(true);
|
||
setIsFinalVideoPlaying(!isFinalVideoPlaying);
|
||
};
|
||
|
||
// 处理最终视频加载完成
|
||
const handleFinalVideoLoaded = () => {
|
||
if (finalVideoRef.current) {
|
||
setFinalVideoReady(true);
|
||
applyVolumeSettings(finalVideoRef.current);
|
||
try {
|
||
setDuration(Number.isFinite(finalVideoRef.current.duration) ? finalVideoRef.current.duration : 0);
|
||
} catch {}
|
||
|
||
// 如果当前状态是应该播放的,尝试播放
|
||
if (isFinalVideoPlaying) {
|
||
finalVideoRef.current.play().catch(error => {
|
||
console.log('最终视频自动播放被阻止:', error);
|
||
setIsFinalVideoPlaying(false);
|
||
});
|
||
}
|
||
}
|
||
};
|
||
|
||
// 处理视频点击 - 首次交互时尝试播放
|
||
const handleVideoClick = () => {
|
||
if (!userHasInteracted && finalVideoRef.current && finalVideoReady) {
|
||
setUserHasInteracted(true);
|
||
if (isFinalVideoPlaying) {
|
||
finalVideoRef.current.play().catch(error => {
|
||
console.log('视频播放失败:', error);
|
||
});
|
||
}
|
||
}
|
||
};
|
||
|
||
// 使用 useMemo 缓存最终视频元素,避免重复创建和请求
|
||
const memoizedFinalVideoElement = useMemo(() => {
|
||
console.log('final', taskObject.final);
|
||
if (!taskObject.final?.url) return null;
|
||
|
||
return (
|
||
<video
|
||
ref={finalVideoRef}
|
||
className="w-full h-full object-contain rounded-lg"
|
||
style={{
|
||
aspectRatio: aspectRatio
|
||
}}
|
||
src={taskObject.final.url}
|
||
autoPlay={isFinalVideoPlaying}
|
||
loop
|
||
playsInline
|
||
preload="metadata"
|
||
poster={getFirstFrame(taskObject.final.url)}
|
||
onLoadedData={handleFinalVideoLoaded}
|
||
onPlay={() => setIsFinalVideoPlaying(true)}
|
||
onPause={() => setIsFinalVideoPlaying(false)}
|
||
onClick={handleVideoClick}
|
||
/>
|
||
);
|
||
}, [taskObject.final?.url, isFinalVideoPlaying, handleFinalVideoLoaded, handleVideoClick]);
|
||
|
||
// 包装编辑按钮点击事件
|
||
const handleEditClick = (tab: string, from?: string) => {
|
||
if (from === 'final') {
|
||
// 暂停视频播放
|
||
finalVideoRef.current?.pause();
|
||
}
|
||
// TODO 点击没有任何事件效果,页面没变化
|
||
setUserHasInteracted(true);
|
||
onEditModalOpen(tab);
|
||
};
|
||
|
||
// 全屏控制
|
||
const toggleFullscreen = () => {
|
||
setUserHasInteracted(true);
|
||
const target = activeVideoRef().current;
|
||
if (!document.fullscreenElement) {
|
||
if (target) {
|
||
target.requestFullscreen?.() ||
|
||
(target as any).webkitRequestFullscreen?.() ||
|
||
(target as any).msRequestFullscreen?.();
|
||
setIsFullscreen(true);
|
||
}
|
||
} else {
|
||
document.exitFullscreen?.() ||
|
||
(document as any).webkitExitFullscreen?.() ||
|
||
(document as any).msExitFullscreen?.();
|
||
setIsFullscreen(false);
|
||
}
|
||
};
|
||
|
||
// 视频播放控制
|
||
useEffect(() => {
|
||
if (mainVideoRef.current) {
|
||
applyVolumeSettings(mainVideoRef.current);
|
||
if (isVideoPlaying) {
|
||
mainVideoRef.current.play().catch(error => {
|
||
console.log('视频播放失败:', error);
|
||
});
|
||
} else {
|
||
mainVideoRef.current.pause();
|
||
}
|
||
try {
|
||
setDuration(Number.isFinite(mainVideoRef.current.duration) ? mainVideoRef.current.duration : 0);
|
||
} catch {}
|
||
}
|
||
}, [isVideoPlaying]);
|
||
|
||
// 当切换视频时重置视频播放
|
||
useEffect(() => {
|
||
if (mainVideoRef.current) {
|
||
applyVolumeSettings(mainVideoRef.current);
|
||
mainVideoRef.current.currentTime = 0;
|
||
if (isVideoPlaying) {
|
||
mainVideoRef.current.play().catch(error => {
|
||
console.log('视频播放失败:', error);
|
||
});
|
||
}
|
||
}
|
||
}, [currentSketchIndex]);
|
||
|
||
// 音量设置变化时应用到所有视频
|
||
useEffect(() => {
|
||
if (mainVideoRef.current) {
|
||
applyVolumeSettings(mainVideoRef.current);
|
||
}
|
||
if (finalVideoRef.current) {
|
||
applyVolumeSettings(finalVideoRef.current);
|
||
}
|
||
}, [volume, isMuted]);
|
||
|
||
// 绑定时间更新(只监听当前活跃的 video)
|
||
useEffect(() => {
|
||
const activeRef = activeVideoRef();
|
||
const activeVideo = activeRef.current;
|
||
|
||
if (!activeVideo) return;
|
||
|
||
const onTimeUpdate = (e: Event) => {
|
||
const el = e.currentTarget as HTMLVideoElement;
|
||
// 只有当事件来源是当前活跃视频时才更新状态
|
||
if (el === activeVideo) {
|
||
setCurrentTime(el.currentTime || 0);
|
||
if (Number.isFinite(el.duration)) setDuration(el.duration);
|
||
}
|
||
};
|
||
|
||
activeVideo.addEventListener('timeupdate', onTimeUpdate);
|
||
activeVideo.addEventListener('loadedmetadata', onTimeUpdate);
|
||
|
||
return () => {
|
||
activeVideo.removeEventListener('timeupdate', onTimeUpdate);
|
||
activeVideo.removeEventListener('loadedmetadata', onTimeUpdate);
|
||
};
|
||
}, [selectedView, taskObject.currentStage]); // 依赖项包含影响activeVideoRef的状态
|
||
|
||
// 当切换视频源时重置进度状态
|
||
useEffect(() => {
|
||
setCurrentTime(0);
|
||
setDuration(0);
|
||
}, [selectedView, currentSketchIndex, taskObject.currentStage]);
|
||
|
||
const activeVideoRef = () => {
|
||
// 根据当前阶段选择活跃的 video 引用
|
||
const effectiveStage = (selectedView === 'final' && taskObject.final?.url)
|
||
? 'final_video'
|
||
: (!['script', 'character', 'scene'].includes(taskObject.currentStage) ? 'video' : taskObject.currentStage);
|
||
return effectiveStage === 'final_video' ? finalVideoRef : mainVideoRef;
|
||
};
|
||
|
||
const progressPercent = duration > 0 ? Math.min(100, Math.max(0, (currentTime / duration) * 100)) : 0;
|
||
|
||
const formatRemaining = (dur: number, cur: number) => {
|
||
const remain = Math.max(0, Math.round(dur - cur));
|
||
const m = Math.floor(remain / 60);
|
||
const s = remain % 60;
|
||
return `-${m}:${s.toString().padStart(2, '0')}`;
|
||
};
|
||
|
||
const seekTo = (pct: number) => {
|
||
const ref = activeVideoRef().current;
|
||
if (!ref || !Number.isFinite(ref.duration)) return;
|
||
const t = (pct / 100) * ref.duration;
|
||
ref.currentTime = t;
|
||
setCurrentTime(t);
|
||
};
|
||
|
||
const requestPip = async () => {
|
||
try {
|
||
const ref = activeVideoRef().current as any;
|
||
if (ref && typeof ref.requestPictureInPicture === 'function') {
|
||
await ref.requestPictureInPicture();
|
||
}
|
||
} catch {}
|
||
};
|
||
|
||
// 监听全屏状态变化
|
||
useEffect(() => {
|
||
const handleFullscreenChange = () => {
|
||
setIsFullscreen(!!document.fullscreenElement);
|
||
};
|
||
|
||
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||
document.addEventListener('webkitfullscreenchange', handleFullscreenChange);
|
||
document.addEventListener('msfullscreenchange', handleFullscreenChange);
|
||
|
||
return () => {
|
||
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||
document.removeEventListener('webkitfullscreenchange', handleFullscreenChange);
|
||
document.removeEventListener('msfullscreenchange', handleFullscreenChange);
|
||
};
|
||
}, []);
|
||
|
||
// 组件卸载时清理视频状态
|
||
useEffect(() => {
|
||
return () => {
|
||
// 清理最终视频状态
|
||
setFinalVideoReady(false);
|
||
if (finalVideoRef.current) {
|
||
finalVideoRef.current.pause();
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
// 加载中
|
||
const renderLoading = () => {
|
||
return (
|
||
<div data-alt="generating-overlay" className="absolute inset-0 bg-black/40 flex items-center justify-center">
|
||
<div data-alt="generating-content" className="relative flex flex-col items-center gap-4">
|
||
{/* 渐变进度环 */}
|
||
<div className="relative w-24 h-24">
|
||
{/* 外层旋转渐变边框 */}
|
||
<div
|
||
className="absolute inset-0 rounded-full opacity-80 animate-spin"
|
||
style={{
|
||
background: 'conic-gradient(from 0deg, transparent 0%, #3b82f6 25%, #60a5fa 50%, #93c5fd 75%, transparent 100%)',
|
||
animation: 'spin 2s linear infinite'
|
||
}}
|
||
/>
|
||
{/* 内层遮罩(形成边框效果) */}
|
||
<div className="absolute inset-[2px] rounded-full bg-black/60 backdrop-blur-sm" />
|
||
</div>
|
||
|
||
{/* 文案 */}
|
||
<div className="text-white text-center">
|
||
<div className="text-sm font-medium" role="status" aria-live="polite" aria-busy="true">
|
||
Generating...
|
||
</div>
|
||
<div className="text-xs text-white/60 mt-1">Processing your request</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 渲染底部通栏控制条(与图2一致)
|
||
const renderBottomControls = (
|
||
isFinal: boolean,
|
||
onPlayToggle: () => void,
|
||
playing: boolean
|
||
) => (
|
||
<div
|
||
className="absolute left-0 right-0 bottom-4 z-[21] px-2"
|
||
data-alt={isFinal ? 'final-controls' : 'video-controls'}
|
||
>
|
||
<div className="flex items-center justify-between">
|
||
{/* 播放/暂停 */}
|
||
<GlassIconButton icon={playing ? Pause : Play} onClick={onPlayToggle} size="sm" />
|
||
{/* 全屏 */}
|
||
<GlassIconButton className="group-hover:block hidden animate-in duration-300" icon={isFullscreen ? Minimize : Maximize} onClick={toggleFullscreen} size="sm" />
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
// 渲染最终成片
|
||
const renderFinalVideo = () => {
|
||
// 使用真实的final数据,如果没有则使用默认值
|
||
|
||
return (
|
||
<div
|
||
className="relative w-full h-full rounded-lg overflow-hidden"
|
||
key={`render-video-${taskObject.final.url}`}
|
||
ref={videoContentRef}
|
||
>
|
||
<div className="relative w-full h-full group">
|
||
{/* 背景模糊的视频 */}
|
||
<motion.div
|
||
className="absolute inset-0 overflow-hidden"
|
||
initial={{ filter: "blur(0px)", scale: 1, opacity: 1 }}
|
||
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
|
||
}}
|
||
src={taskObject.final.url}
|
||
loop
|
||
playsInline
|
||
muted
|
||
poster={getFirstFrame(taskObject.final.url)}
|
||
preload="none"
|
||
/>
|
||
</motion.div>
|
||
|
||
{/* 最终成片视频 */}
|
||
<motion.div
|
||
initial={{ clipPath: "inset(0 50% 0 50%)", filter: "blur(10px)" }}
|
||
animate={{ clipPath: "inset(0 0% 0 0%)", filter: "blur(0px)" }}
|
||
transition={{
|
||
clipPath: { duration: 1.2, ease: [0.43, 0.13, 0.23, 0.96] },
|
||
filter: { duration: 0.6, delay: 0.3 }
|
||
}}
|
||
className="relative z-10 w-full h-full"
|
||
>
|
||
{memoizedFinalVideoElement}
|
||
</motion.div>
|
||
|
||
{/* 编辑和剪辑按钮 */}
|
||
<div className="absolute top-2 right-2 z-[21] flex items-center gap-2 transition-right duration-100"
|
||
style={{
|
||
right: aspectRatio === '16:9' ? toosBtnRight : ''
|
||
}}>
|
||
<Tooltip placement="top" title='Edit'>
|
||
<GlassIconButton
|
||
icon={Edit3}
|
||
size="sm"
|
||
onClick={() => handleEditClick('3', 'final')}
|
||
/>
|
||
</Tooltip>
|
||
{/* 下载所有视频按钮 */}
|
||
<Tooltip placement="top" title="Download all videos">
|
||
<GlassIconButton icon={ArrowDownWideNarrow} size='sm' loading={isLoadingDownloadAllVideosBtn} onClick={ async () => {
|
||
setIsLoadingDownloadAllVideosBtn(true);
|
||
await downloadAllVideos(taskObject.videos.data.flatMap((video: any) => video.urls));
|
||
setIsLoadingDownloadAllVideosBtn(false);
|
||
}} />
|
||
</Tooltip>
|
||
{/* 下载按钮 */}
|
||
<Tooltip placement="top" title="Download video">
|
||
<GlassIconButton icon={Download} loading={isLoadingDownloadBtn} size='sm' onClick={async () => {
|
||
setIsLoadingDownloadBtn(true);
|
||
await downloadVideo(taskObject.final.url);
|
||
setIsLoadingDownloadBtn(false);
|
||
}} />
|
||
</Tooltip>
|
||
{showGotoCutButton && (
|
||
<Tooltip placement="top" title='Go to AI-powered editing platform'>
|
||
<GlassIconButton icon={Scissors} size='sm' onClick={onGotoCut} />
|
||
</Tooltip>
|
||
)}
|
||
</div>
|
||
|
||
{/* 底部通栏控制区域 */}
|
||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.6 }}>
|
||
{renderBottomControls(true, toggleFinalVideoPlay, isFinalVideoPlaying)}
|
||
</motion.div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// 渲染视频内容
|
||
const renderVideoContent = (onGotoCut: () => void) => {
|
||
if (!taskObject.videos.data[currentSketchIndex]) return null;
|
||
const urls = taskObject.videos.data[currentSketchIndex]?.urls ? taskObject.videos.data[currentSketchIndex]?.urls.join(',') : '';
|
||
return (
|
||
<div
|
||
className="relative w-full h-full rounded-lg group"
|
||
key={`render-video-${urls}`}
|
||
ref={videoContentRef}
|
||
style={{
|
||
width: taskObject.videos.data[currentSketchIndex].video_status !== 1 ? placeholderWidth : '100%'
|
||
}}
|
||
>
|
||
{/* 背景模糊的图片 */}
|
||
{taskObject.videos.data[currentSketchIndex].video_status !== 1 && (
|
||
<div className="absolute inset-0 overflow-hidden z-20">
|
||
{/* 生成中 */}
|
||
{taskObject.videos.data[currentSketchIndex].video_status === 0 && (
|
||
renderLoading()
|
||
)}
|
||
{/* 生成失败 */}
|
||
{taskObject.videos.data[currentSketchIndex].video_status === 2 && (
|
||
<div className="absolute inset-0 bg-red-500/5 flex items-center justify-center">
|
||
<div
|
||
className="text-[#813b9dcc] text-2xl font-bold flex items-center gap-2"
|
||
>
|
||
<RotateCcw className="w-10 h-10 cursor-pointer" onClick={() => {
|
||
const video = taskObject.videos.data[currentSketchIndex];
|
||
if (onRetryVideo && video?.video_id) {
|
||
onRetryVideo(video.video_id);
|
||
}
|
||
}} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
|
||
{/* 视频 多个 取第一个 */}
|
||
{ taskObject.videos.data[currentSketchIndex].urls && taskObject.videos.data[currentSketchIndex].urls.length > 0 && (
|
||
<>
|
||
<motion.div
|
||
initial={{ clipPath: "inset(0 100% 0 0)" }}
|
||
animate={{ clipPath: "inset(0 0% 0 0)" }}
|
||
transition={{ duration: 0.8, ease: [0.43, 0.13, 0.23, 0.96] }}
|
||
className="relative z-10 w-full h-full"
|
||
>
|
||
<video
|
||
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
|
||
}}
|
||
src={taskObject.videos.data[currentSketchIndex].urls[0]}
|
||
poster={getFirstFrame(taskObject.videos.data[currentSketchIndex].urls[0])}
|
||
preload="none"
|
||
autoPlay={isVideoPlaying}
|
||
loop={true}
|
||
playsInline
|
||
onEnded={() => {
|
||
if (isVideoPlaying) {
|
||
// 自动切换到下一个视频的逻辑在父组件处理
|
||
}
|
||
}}
|
||
/>
|
||
|
||
{/* 视频编辑覆盖层 */}
|
||
{enableVideoEdit && isVideoEditMode && (
|
||
<VideoEditOverlay
|
||
projectId={projectId || ''}
|
||
userId={JSON.parse(localStorage.getItem("currentUser") || '{}').id || 0}
|
||
currentVideo={{
|
||
id: taskObject.videos.data[currentSketchIndex].video_id,
|
||
url: taskObject.videos.data[currentSketchIndex].urls[0],
|
||
duration: 30 // 默认时长,可以从视频元素获取
|
||
}}
|
||
videoRef={mainVideoRef}
|
||
enabled={isVideoEditMode}
|
||
onDescriptionSubmit={onVideoEditDescriptionSubmit}
|
||
className="rounded-lg"
|
||
/>
|
||
)}
|
||
</motion.div>
|
||
|
||
{/* 跳转剪辑按钮 */}
|
||
<div className="absolute top-2 right-2 z-[21] flex items-center gap-2 transition-right duration-100" style={{
|
||
right: aspectRatio === '16:9' ? toosBtnRight : ''
|
||
}}>
|
||
{/* 视频编辑模式切换按钮 - 通过服务器配置控制显示 */}
|
||
{enableVideoEdit && showVideoModification && (
|
||
<Tooltip placement="top" title={isVideoEditMode ? "Exit edit mode" : "Enter edit mode"}>
|
||
<GlassIconButton
|
||
icon={PenTool}
|
||
size='sm'
|
||
onClick={() => {
|
||
console.log('🖊️ 钢笔图标被点击,切换编辑模式:', !isVideoEditMode);
|
||
setIsVideoEditMode(!isVideoEditMode);
|
||
}}
|
||
className={isVideoEditMode ? 'bg-blue-500/20 border-blue-500/50' : ''}
|
||
/>
|
||
</Tooltip>
|
||
)}
|
||
{/* 添加到chat去编辑 按钮 */}
|
||
<Tooltip placement="top" title="Edit video with chat">
|
||
<GlassIconButton icon={MessageCircleMore} size='sm' onClick={() => {
|
||
const currentVideo = taskObject.videos.data[currentSketchIndex];
|
||
if (currentVideo && currentVideo.urls && currentVideo.urls.length > 0 && setVideoPreview) {
|
||
setVideoPreview(currentVideo.urls[0], currentVideo.video_id);
|
||
if (onOpenChat) onOpenChat();
|
||
}
|
||
}} />
|
||
</Tooltip>
|
||
{/* 下载所有视频按钮 */}
|
||
<Tooltip placement="top" title="Download all videos">
|
||
<GlassIconButton icon={ArrowDownWideNarrow} size='sm' loading={isLoadingDownloadAllVideosBtn} onClick={ async () => {
|
||
setIsLoadingDownloadAllVideosBtn(true);
|
||
await downloadAllVideos(taskObject.videos.data.flatMap((video: any) => video.urls));
|
||
setIsLoadingDownloadAllVideosBtn(false);
|
||
}} />
|
||
</Tooltip>
|
||
{/* 下载按钮 */}
|
||
<Tooltip placement="top" title="Download video">
|
||
<GlassIconButton icon={Download} loading={isLoadingDownloadBtn} size='sm' onClick={async () => {
|
||
const currentVideo = taskObject.videos.data[currentSketchIndex];
|
||
if (currentVideo && currentVideo.urls && currentVideo.urls.length > 0) {
|
||
setIsLoadingDownloadBtn(true);
|
||
await downloadVideo(currentVideo.urls[0]);
|
||
setIsLoadingDownloadBtn(false);
|
||
}
|
||
}} />
|
||
</Tooltip>
|
||
{/* 跳转剪辑按钮 */}
|
||
{showGotoCutButton && (
|
||
<Tooltip placement="top" title='Go to AI-powered editing platform'>
|
||
<GlassIconButton icon={Scissors} size='sm' onClick={onGotoCut} />
|
||
</Tooltip>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* 操作按钮组 */}
|
||
{/* <AnimatePresence>
|
||
<motion.div
|
||
className="absolute top-4 right-4 gap-2 z-[21] hidden group-hover:flex"
|
||
initial={{ opacity: 0, y: -10 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
exit={{ opacity: 0, y: -10 }}
|
||
transition={{ duration: 0.2 }}
|
||
>
|
||
<GlassIconButton
|
||
icon={Edit3}
|
||
onClick={() => handleEditClick('3')}
|
||
/>
|
||
</motion.div>
|
||
</AnimatePresence> */}
|
||
|
||
{/* 底部通栏控制区域(仅生成成功时显示) */}
|
||
{ taskObject.videos.data[currentSketchIndex].video_status === 1 && (
|
||
<AnimatePresence>
|
||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.2 }}>
|
||
{renderBottomControls(false, onToggleVideoPlay, isVideoPlaying)}
|
||
</motion.div>
|
||
</AnimatePresence>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// 渲染分镜草图
|
||
const renderSketchContent = (currentSketch: any) => {
|
||
if (!currentSketch) return null;
|
||
|
||
return (
|
||
<div
|
||
className="relative w-full h-full rounded-lg group overflow-hidden"
|
||
key={`render-sketch-${currentSketch.url}`}
|
||
style={{
|
||
width: currentSketch.status === 1 ? '100%' : placeholderWidth
|
||
}}
|
||
>
|
||
{/* 状态 */}
|
||
{currentSketch.status === 0 && (
|
||
renderLoading()
|
||
)}
|
||
{currentSketch.status === 2 && (
|
||
<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 */}
|
||
{currentSketch.status === 1 && (
|
||
<AnimatePresence mode="wait">
|
||
<motion.img
|
||
key={currentSketch.url}
|
||
src={currentSketch.url}
|
||
className="w-full h-full rounded-lg object-contain"
|
||
// 用 circle clip-path 实现“扩散”
|
||
initial={{ clipPath: "circle(0% at 50% 50%)" }}
|
||
animate={{ clipPath: "circle(150% at 50% 50%)" }}
|
||
exit={{ opacity: 0 }}
|
||
transition={{ duration: 1, ease: "easeInOut" }}
|
||
/>
|
||
</AnimatePresence>
|
||
)}
|
||
|
||
<div className="absolute top-0 left-0 right-0 p-2">
|
||
{/* 角色类型 */}
|
||
{currentSketch.type === 'role' && (
|
||
<div className="inline-flex items-center px-2 py-1 rounded-full bg-purple-500/20 backdrop-blur-sm">
|
||
<span className="text-xs text-purple-400">Role: {currentSketch.name}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 操作按钮组 */}
|
||
{/* <AnimatePresence>
|
||
<motion.div
|
||
className="absolute top-4 right-4 gap-2 hidden group-hover:flex"
|
||
initial={{ opacity: 0, y: -10 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
exit={{ opacity: 0, y: -10 }}
|
||
transition={{ duration: 0.2 }}
|
||
>
|
||
<GlassIconButton
|
||
icon={Edit3}
|
||
onClick={() => handleEditClick('1')}
|
||
/>
|
||
</motion.div>
|
||
</AnimatePresence> */}
|
||
|
||
{/* 底部播放按钮 */}
|
||
<AnimatePresence>
|
||
<motion.div
|
||
className="absolute bottom-4 left-4"
|
||
initial={{ opacity: 0, scale: 0.8 }}
|
||
animate={{ opacity: 1, scale: 1 }}
|
||
exit={{ opacity: 0, scale: 0.8 }}
|
||
transition={{ duration: 0.2 }}
|
||
>
|
||
<motion.div
|
||
whileHover={{ scale: 1.1 }}
|
||
whileTap={{ scale: 0.9 }}
|
||
className="relative"
|
||
>
|
||
|
||
</motion.div>
|
||
</motion.div>
|
||
</AnimatePresence>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// 渲染剧本
|
||
const renderScriptContent = () => {
|
||
return (
|
||
<div className="relative h-full rounded-lg overflow-hidden p-2"
|
||
style={{
|
||
width: 'calc(100vw - 8rem)'
|
||
}}>
|
||
{scriptData ? (
|
||
<ScriptRenderer
|
||
data={scriptData}
|
||
setIsPauseWorkFlow={setIsPauseWorkFlow}
|
||
setAnyAttribute={setAnyAttribute}
|
||
isPauseWorkFlow={isPauseWorkFlow}
|
||
applyScript={applyScript}
|
||
mode={mode}
|
||
/>
|
||
) : (
|
||
<ScriptLoading isCompleted={!!scriptData} />
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// 计算生效阶段:selectedView 优先于 taskObject.currentStage
|
||
const effectiveStage = (selectedView === 'final' && taskObject.final?.url)
|
||
? 'final_video'
|
||
: (!['script', 'character', 'scene'].includes(taskObject.currentStage) ? 'video' : taskObject.currentStage);
|
||
|
||
// 根据当前步骤渲染对应内容
|
||
if (effectiveStage === 'final_video') {
|
||
return renderFinalVideo();
|
||
}
|
||
|
||
if (effectiveStage === 'video') {
|
||
return renderVideoContent(onGotoCut);
|
||
}
|
||
|
||
if (effectiveStage === 'script') {
|
||
return renderScriptContent();
|
||
}
|
||
|
||
if (effectiveStage === 'scene' || effectiveStage === 'character') {
|
||
const allSketches = [...(taskObject.roles?.data || []), ...(taskObject.scenes?.data || [])];
|
||
const currentSketch = allSketches[currentSketchIndex];
|
||
return renderSketchContent(currentSketch);
|
||
}
|
||
|
||
|
||
|
||
return null;
|
||
});
|