最终视频与分镜列表切换

This commit is contained in:
北枳 2025-09-20 19:24:18 +08:00
parent 5b35e274b9
commit 3cf9915877
5 changed files with 125 additions and 14 deletions

View File

@ -19,6 +19,7 @@ import { Drawer, Tooltip, notification } from 'antd';
import { showEditingNotification } from "@/components/pages/work-flow/editing-notification"; import { showEditingNotification } from "@/components/pages/work-flow/editing-notification";
// import { AIEditingIframeButton } from './work-flow/ai-editing-iframe'; // import { AIEditingIframeButton } from './work-flow/ai-editing-iframe';
import { exportVideoWithRetry } from '@/utils/export-service'; import { exportVideoWithRetry } from '@/utils/export-service';
import { getFirstFrame } from '@/utils/tools';
// 临时禁用视频编辑功能 // 临时禁用视频编辑功能
// import { EditPoint as EditPointType } from './work-flow/video-edit/types'; // import { EditPoint as EditPointType } from './work-flow/video-edit/types';
import { AIEditingIframeButton } from './work-flow/ai-editing-iframe'; import { AIEditingIframeButton } from './work-flow/ai-editing-iframe';
@ -51,6 +52,8 @@ const WorkFlow = React.memo(function WorkFlow() {
const [previewVideoUrl, setPreviewVideoUrl] = React.useState<string | null>(null); const [previewVideoUrl, setPreviewVideoUrl] = React.useState<string | null>(null);
const [previewVideoId, setPreviewVideoId] = React.useState<string | null>(null); const [previewVideoId, setPreviewVideoId] = React.useState<string | null>(null);
const [isFocusChatInput, setIsFocusChatInput] = React.useState(false); const [isFocusChatInput, setIsFocusChatInput] = React.useState(false);
const [selectedView, setSelectedView] = React.useState<'final' | 'video' | null>(null);
const [isFinalBarOpen, setIsFinalBarOpen] = React.useState(true);
const [aiEditingResult, setAiEditingResult] = React.useState<any>(null); const [aiEditingResult, setAiEditingResult] = React.useState<any>(null);
// const aiEditingButtonRef = useRef<{ handleAIEditing: () => Promise<void> }>(null); // const aiEditingButtonRef = useRef<{ handleAIEditing: () => Promise<void> }>(null);
@ -236,6 +239,13 @@ const WorkFlow = React.memo(function WorkFlow() {
console.log('changedIndex_work-flow', currentSketchIndex, taskObject); console.log('changedIndex_work-flow', currentSketchIndex, taskObject);
}, [currentSketchIndex, taskObject]); }, [currentSketchIndex, taskObject]);
// 当最终视频出现时,默认选中最终视频
useEffect(() => {
if (taskObject?.final?.url && selectedView === null) {
setSelectedView('final');
}
}, [taskObject?.final?.url, selectedView]);
// 监听粗剪是否完成 // 监听粗剪是否完成
useEffect(() => { useEffect(() => {
console.log('🎬 final video useEffect triggered:', { console.log('🎬 final video useEffect triggered:', {
@ -409,6 +419,7 @@ Please process this video editing request.`;
title={taskObject.title} title={taskObject.title}
current={currentSketchIndex + 1} current={currentSketchIndex + 1}
taskObject={taskObject} taskObject={taskObject}
selectedView={selectedView}
currentLoadingText={currentLoadingText} currentLoadingText={currentLoadingText}
/> />
) : ( ) : (
@ -431,11 +442,44 @@ Please process this video editing request.`;
> >
{isDesktop ? ( {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 ${['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}>
{/* 左侧最终视频缩略图栏(仅桌面) */}
{taskObject?.final?.url && (
<div
className="absolute -left-36 top-0 z-[50]"
data-alt="final-sidebar"
>
<div className={`flex items-start`}>
{isFinalBarOpen && (
<div
className="w-28 max-h-[60vh] overflow-y-auto rounded-lg backdrop-blur-lg bg-black/30 border border-white/20 shadow-xl p-2 mr-2"
data-alt="final-thumbnails"
>
{/* 预留历史列表,目前仅展示当前最终视频 */}
<button
type="button"
onClick={() => setSelectedView('final')}
className={`block w-full overflow-hidden rounded-md border ${selectedView === 'final' ? 'border-blue-500' : 'border-white/20'}`}
data-alt="final-thumb-item"
aria-label="Select final video"
>
<img
src={getFirstFrame(taskObject.final.url)}
alt="final"
className="w-full h-auto object-cover"
/>
<div className="text-xs text-white/80 text-center py-1">Final</div>
</button>
</div>
)}
</div>
</div>
)}
<MediaViewer <MediaViewer
taskObject={taskObject} taskObject={taskObject}
scriptData={scriptData} scriptData={scriptData}
currentSketchIndex={currentSketchIndex} currentSketchIndex={currentSketchIndex}
isVideoPlaying={isVideoPlaying} isVideoPlaying={isVideoPlaying}
selectedView={selectedView}
onEditModalOpen={handleEditModalOpen} onEditModalOpen={handleEditModalOpen}
onToggleVideoPlay={toggleVideoPlay} onToggleVideoPlay={toggleVideoPlay}
setIsPauseWorkFlow={setIsPauseWorkFlow} setIsPauseWorkFlow={setIsPauseWorkFlow}
@ -459,6 +503,7 @@ Please process this video editing request.`;
taskObject={taskObject} taskObject={taskObject}
scriptData={scriptData} scriptData={scriptData}
currentSketchIndex={currentSketchIndex} currentSketchIndex={currentSketchIndex}
selectedView={selectedView}
mode={mode} mode={mode}
setIsPauseWorkFlow={setIsPauseWorkFlow} setIsPauseWorkFlow={setIsPauseWorkFlow}
setAnyAttribute={setAnyAttribute} setAnyAttribute={setAnyAttribute}
@ -474,6 +519,7 @@ Please process this video editing request.`;
onGotoCut={generateEditPlan} onGotoCut={generateEditPlan}
isSmartChatBoxOpen={isSmartChatBoxOpen} isSmartChatBoxOpen={isSmartChatBoxOpen}
onRetryVideo={(video_id) => handleRetryVideo(video_id)} onRetryVideo={(video_id) => handleRetryVideo(video_id)}
onSelectView={(view) => setSelectedView(view)}
// 临时禁用视频编辑功能: enableVideoEdit={true} onVideoEditDescriptionSubmit={handleVideoEditDescriptionSubmit} // 临时禁用视频编辑功能: enableVideoEdit={true} onVideoEditDescriptionSubmit={handleVideoEditDescriptionSubmit}
/> />
)} )}
@ -484,9 +530,13 @@ Please process this video editing request.`;
isDisabledFocus={isEditModalOpen || isPauseWorkFlow || isFocusChatInput} isDisabledFocus={isEditModalOpen || isPauseWorkFlow || isFocusChatInput}
taskObject={taskObject} taskObject={taskObject}
currentSketchIndex={currentSketchIndex} currentSketchIndex={currentSketchIndex}
onSketchSelect={setCurrentSketchIndex} onSketchSelect={(index) => {
setSelectedView('video');
setCurrentSketchIndex(index);
}}
onRetryVideo={handleRetryVideo} onRetryVideo={handleRetryVideo}
className={isDesktop ? 'auto-cols-[20%]' : (isTablet ? 'auto-cols-[25%]' : 'auto-cols-[40%]')} className={isDesktop ? 'auto-cols-[20%]' : (isTablet ? 'auto-cols-[25%]' : 'auto-cols-[40%]')}
selectedView={selectedView}
/> />
</div> </div>
)} )}

View File

@ -18,6 +18,8 @@ interface H5MediaViewerProps {
scriptData: any; scriptData: any;
/** 当前索引(视频/分镜阶段用于定位) */ /** 当前索引(视频/分镜阶段用于定位) */
currentSketchIndex: number; currentSketchIndex: number;
/** 选中视图final 或 video用于覆盖阶段渲染 */
selectedView?: 'final' | 'video' | null;
/** 渲染模式(仅剧本阶段透传) */ /** 渲染模式(仅剧本阶段透传) */
mode: string; mode: string;
/** 以下为剧本阶段透传必需项(与桌面版保持一致) */ /** 以下为剧本阶段透传必需项(与桌面版保持一致) */
@ -39,6 +41,8 @@ interface H5MediaViewerProps {
isSmartChatBoxOpen?: boolean; isSmartChatBoxOpen?: boolean;
/** 失败重试生成视频 */ /** 失败重试生成视频 */
onRetryVideo?: (video_id: string) => void; onRetryVideo?: (video_id: string) => void;
/** 切换选择视图final 或 video */
onSelectView?: (view: 'final' | 'video') => void;
} }
/** /**
@ -50,6 +54,7 @@ export function H5MediaViewer({
taskObject, taskObject,
scriptData, scriptData,
currentSketchIndex, currentSketchIndex,
selectedView,
mode, mode,
setIsPauseWorkFlow, setIsPauseWorkFlow,
setAnyAttribute, setAnyAttribute,
@ -61,7 +66,8 @@ export function H5MediaViewer({
showGotoCutButton, showGotoCutButton,
onGotoCut, onGotoCut,
isSmartChatBoxOpen, isSmartChatBoxOpen,
onRetryVideo onRetryVideo,
onSelectView
}: H5MediaViewerProps) { }: H5MediaViewerProps) {
const carouselRef = useRef<CarouselRef>(null); const carouselRef = useRef<CarouselRef>(null);
const videoRefs = useRef<Array<HTMLVideoElement | null>>([]); const videoRefs = useRef<Array<HTMLVideoElement | null>>([]);
@ -69,9 +75,12 @@ export function H5MediaViewer({
const [activeIndex, setActiveIndex] = useState<number>(0); const [activeIndex, setActiveIndex] = useState<number>(0);
const [isPlaying, setIsPlaying] = useState<boolean>(false); const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [isCatalogOpen, setIsCatalogOpen] = useState<boolean>(false); const [isCatalogOpen, setIsCatalogOpen] = useState<boolean>(false);
const [isFinalBarOpen, setIsFinalBarOpen] = useState<boolean>(true);
// 计算当前阶段类型 // 计算当前阶段类型
const stage = taskObject.currentStage; const stage = (selectedView === 'final' && taskObject.final?.url)
? 'final_video'
: (selectedView === 'video' ? 'video' : taskObject.currentStage);
// 生成各阶段对应的 slides 数据 // 生成各阶段对应的 slides 数据
const videoUrls = useMemo(() => { const videoUrls = useMemo(() => {
@ -385,6 +394,27 @@ export function H5MediaViewer({
// 其他阶段:使用 Carousel // 其他阶段:使用 Carousel
return ( return (
<div ref={rootRef} data-alt="h5-media-viewer" className="w-[100vw] relative px-4"> <div ref={rootRef} data-alt="h5-media-viewer" className="w-[100vw] relative px-4">
{/* 左侧最终视频缩略图栏H5 */}
{taskObject?.final?.url && (
<div className="absolute left-4 top-0 z-[60]" data-alt="final-sidebar-h5">
<div className="flex items-start">
{isFinalBarOpen && (
<div className="w-20 max-h-[50vh] overflow-y-auto rounded-md backdrop-blur-lg bg-black/30 border border-white/20 shadow-xl p-1 mr-2" data-alt="final-thumbnails">
<button
type="button"
onClick={() => onSelectView && onSelectView('final')}
className={`block w-full overflow-hidden rounded border ${selectedView === 'final' ? 'border-blue-500' : 'border-white/20'}`}
data-alt="final-thumb-item"
aria-label="Select final video"
>
<img src={getFirstFrame(taskObject.final.url)} alt="final" className="w-full h-auto object-cover" />
<div className="text-[10px] text-white/80 text-center py-0.5">Final</div>
</button>
</div>
)}
</div>
</div>
)}
{stage === 'final_video' && videoUrls.length > 0 && renderVideoSlides()} {stage === 'final_video' && videoUrls.length > 0 && renderVideoSlides()}
{stage === 'video' && videoUrls.length > 0 && renderVideoSlides()} {stage === 'video' && videoUrls.length > 0 && renderVideoSlides()}
{(stage === 'scene' || stage === 'character') && imageUrls.length > 0 && renderImageSlides()} {(stage === 'scene' || stage === 'character') && imageUrls.length > 0 && renderImageSlides()}

View File

@ -14,6 +14,8 @@ interface H5TaskInfoProps {
taskObject: TaskObject taskObject: TaskObject
className?: string className?: string
currentLoadingText: string currentLoadingText: string
/** 选中视图final 或 video */
selectedView?: 'final' | 'video' | null
} }
const H5TaskInfo: React.FC<H5TaskInfoProps> = ({ const H5TaskInfo: React.FC<H5TaskInfoProps> = ({
@ -21,7 +23,8 @@ const H5TaskInfo: React.FC<H5TaskInfoProps> = ({
current, current,
taskObject, taskObject,
className, className,
currentLoadingText currentLoadingText,
selectedView
}) => { }) => {
type StageIndex = 0 | 1 | 2 | 3 type StageIndex = 0 | 1 | 2 | 3
@ -72,6 +75,25 @@ const H5TaskInfo: React.FC<H5TaskInfoProps> = ({
return 0 return 0
}, [taskObject, current, total]) }, [taskObject, current, total])
// 构造副标题文本:优先根据 selectedView 覆盖
const subtitle = useMemo(() => {
if (selectedView === 'final' && taskObject.final?.url) {
return 'Final 1/1'
}
if (selectedView === 'video') {
const videosTotal = taskObject.videos?.total_count || taskObject.videos?.data?.length || 0
return `Shots ${Math.max(displayCurrent, 1)}/${Math.max(videosTotal, 1)}`
}
// 回退到原有逻辑
if (taskObject.currentStage === 'video' || taskObject.currentStage === 'final_video') {
return `Shots ${Math.max(displayCurrent, 1)}/${Math.max(total, 1)}`
}
if (taskObject.currentStage === 'scene' || taskObject.currentStage === 'character') {
return `Roles & Scenes ${Math.max(displayCurrent, 1)}/${Math.max(total, 1)}`
}
return null
}, [selectedView, taskObject, displayCurrent, total])
return ( return (
<div <div
data-alt="h5-header" data-alt="h5-header"
@ -89,9 +111,9 @@ const H5TaskInfo: React.FC<H5TaskInfoProps> = ({
> >
{title || '...'} {title || '...'}
</h1> </h1>
{shouldShowCount && ( {shouldShowCount && subtitle && (
<span data-alt="shot-count" className="flex items-center gap-4 text-sm text-slate-300"> <span data-alt="shot-count" className="flex items-center gap-4 text-sm text-slate-300">
{taskObject.currentStage === 'video' ? 'Shots' : 'Roles & Scenes '} {displayCurrent}/{Math.max(total, 1)} {subtitle}
</span> </span>
)} )}

View File

@ -21,6 +21,7 @@ interface MediaViewerProps {
scriptData: any; scriptData: any;
currentSketchIndex: number; currentSketchIndex: number;
isVideoPlaying: boolean; isVideoPlaying: boolean;
selectedView?: 'final' | 'video' | null;
onEditModalOpen: (tab: string) => void; onEditModalOpen: (tab: string) => void;
onToggleVideoPlay: () => void; onToggleVideoPlay: () => void;
setIsPauseWorkFlow: (isPause: boolean) => void; setIsPauseWorkFlow: (isPause: boolean) => void;
@ -44,6 +45,7 @@ export const MediaViewer = React.memo(function MediaViewer({
scriptData, scriptData,
currentSketchIndex, currentSketchIndex,
isVideoPlaying, isVideoPlaying,
selectedView,
onEditModalOpen, onEditModalOpen,
onToggleVideoPlay, onToggleVideoPlay,
setIsPauseWorkFlow, setIsPauseWorkFlow,
@ -734,20 +736,25 @@ export const MediaViewer = React.memo(function MediaViewer({
); );
}; };
// 计算生效阶段selectedView 优先于 taskObject.currentStage
const effectiveStage = (selectedView === 'final' && taskObject.final?.url)
? 'final_video'
: (selectedView === 'video' ? 'video' : taskObject.currentStage);
// 根据当前步骤渲染对应内容 // 根据当前步骤渲染对应内容
if (taskObject.currentStage === 'final_video') { if (effectiveStage === 'final_video') {
return renderFinalVideo(); return renderFinalVideo();
} }
if (taskObject.currentStage === 'video') { if (effectiveStage === 'video') {
return renderVideoContent(onGotoCut); return renderVideoContent(onGotoCut);
} }
if (taskObject.currentStage === 'script') { if (effectiveStage === 'script') {
return renderScriptContent(); return renderScriptContent();
} }
if (taskObject.currentStage === 'scene' || taskObject.currentStage === 'character') { if (effectiveStage === 'scene' || effectiveStage === 'character') {
return renderSketchContent([...taskObject.roles.data, ...taskObject.scenes.data][currentSketchIndex]); return renderSketchContent([...taskObject.roles.data, ...taskObject.scenes.data][currentSketchIndex]);
} }

View File

@ -15,6 +15,7 @@ interface ThumbnailGridProps {
onSketchSelect: (index: number) => void; onSketchSelect: (index: number) => void;
onRetryVideo: (video_id: string) => void; onRetryVideo: (video_id: string) => void;
className: string; className: string;
selectedView?: 'final' | 'video' | null;
} }
/** /**
@ -26,7 +27,8 @@ export function ThumbnailGrid({
currentSketchIndex, currentSketchIndex,
onSketchSelect, onSketchSelect,
onRetryVideo, onRetryVideo,
className className,
selectedView
}: ThumbnailGridProps) { }: ThumbnailGridProps) {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null); const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
@ -185,7 +187,7 @@ export function ThumbnailGrid({
<div <div
key={`video-${urls}-${index}`} key={`video-${urls}-${index}`}
className={`relative aspect-video rounded-lg overflow-hidden className={`relative aspect-video rounded-lg overflow-hidden
${(currentSketchIndex === index && !disabled) ? 'ring-2 ring-blue-500 z-10' : 'hover:ring-2 hover:ring-blue-500/50'}`} ${(currentSketchIndex === index && !disabled && selectedView !== 'final') ? 'ring-2 ring-blue-500 z-10' : 'hover:ring-2 hover:ring-blue-500/50'}`}
onClick={() => !isDragging && !disabled && onSketchSelect(index)} onClick={() => !isDragging && !disabled && onSketchSelect(index)}
> >
@ -294,6 +296,7 @@ export function ThumbnailGrid({
className="w-full h-full object-cover select-none" className="w-full h-full object-cover select-none"
src={sketch.url} src={sketch.url}
draggable="false" draggable="false"
alt={sketch.type ? String(sketch.type) : 'sketch'}
/> />
</div> </div>
)} )}
@ -343,9 +346,8 @@ export function ThumbnailGrid({
onFocus={() => setIsFocused(true)} onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)} onBlur={() => setIsFocused(false)}
> >
{taskObject.currentStage === 'video' && renderVideoThumbnails()} {(taskObject.currentStage === 'video' || taskObject.currentStage === 'final_video') && renderVideoThumbnails()}
{(taskObject.currentStage === 'scene' || taskObject.currentStage === 'character') && renderSketchThumbnails(getCurrentData())} {(taskObject.currentStage === 'scene' || taskObject.currentStage === 'character') && renderSketchThumbnails(getCurrentData())}
{taskObject.currentStage === 'final_video' && renderVideoThumbnails(true)}
</div> </div>
); );
} }