forked from 77media/video-flow
555 lines
20 KiB
TypeScript
555 lines
20 KiB
TypeScript
"use client"
|
||
import React, { useRef, useEffect, useCallback } from "react";
|
||
import "./style/work-flow.css";
|
||
|
||
import { EditModal } from "@/components/ui/edit-modal";
|
||
import { TaskInfo } from "./work-flow/task-info";
|
||
import { MediaViewer } from "./work-flow/media-viewer";
|
||
import { ThumbnailGrid } from "./work-flow/thumbnail-grid";
|
||
import { useWorkflowData } from "./work-flow/use-workflow-data";
|
||
import { usePlaybackControls } from "./work-flow/use-playback-controls";
|
||
import { Bot, TestTube } from "lucide-react";
|
||
import { GlassIconButton } from '@/components/ui/glass-icon-button';
|
||
import { SaveEditUseCase } from "@/app/service/usecase/SaveEditUseCase";
|
||
import { useSearchParams } from "next/navigation";
|
||
import SmartChatBox from "@/components/SmartChatBox/SmartChatBox";
|
||
import { Drawer, Tooltip, notification } from 'antd';
|
||
import { showEditingNotification } from "@/components/pages/work-flow/editing-notification";
|
||
// import { AIEditingIframeButton } from './work-flow/ai-editing-iframe';
|
||
import { exportVideoWithRetry } from '@/utils/export-service';
|
||
|
||
const WorkFlow = React.memo(function WorkFlow() {
|
||
useEffect(() => {
|
||
console.log("init-WorkFlow");
|
||
return () => {
|
||
console.log("unmount-WorkFlow");
|
||
// 销毁编辑通知
|
||
if (editingNotificationKey.current) {
|
||
notification.destroy(editingNotificationKey.current);
|
||
}
|
||
};
|
||
}, []);
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
const [isEditModalOpen, setIsEditModalOpen] = React.useState(false);
|
||
const [activeEditTab, setActiveEditTab] = React.useState('1');
|
||
const [isSmartChatBoxOpen, setIsSmartChatBoxOpen] = React.useState(true);
|
||
const [previewVideoUrl, setPreviewVideoUrl] = React.useState<string | null>(null);
|
||
const [previewVideoId, setPreviewVideoId] = React.useState<string | null>(null);
|
||
const [isFocusChatInput, setIsFocusChatInput] = React.useState(false);
|
||
|
||
const [aiEditingResult, setAiEditingResult] = React.useState<any>(null);
|
||
// const aiEditingButtonRef = useRef<{ handleAIEditing: () => Promise<void> }>(null);
|
||
const [editingStatus, setEditingStatus] = React.useState<'initial' | 'idle' | 'success' | 'error'>('initial');
|
||
// const [iframeAiEditingKey, setIframeAiEditingKey] = React.useState<string>(`iframe-ai-editing-${Date.now()}`);
|
||
const [isEditingInProgress, setIsEditingInProgress] = React.useState(false);
|
||
const isEditingInProgressRef = useRef(false);
|
||
|
||
// 导出进度状态
|
||
const [exportProgress, setExportProgress] = React.useState<{
|
||
status: 'processing' | 'completed' | 'failed';
|
||
percentage: number;
|
||
message: string;
|
||
stage?: string;
|
||
taskId?: string;
|
||
} | null>(null);
|
||
|
||
const searchParams = useSearchParams();
|
||
const episodeId = searchParams.get('episodeId') || '';
|
||
|
||
const userId = JSON.parse(localStorage.getItem("currentUser") || '{}').id || NaN;
|
||
|
||
SaveEditUseCase.setProjectId(episodeId);
|
||
let editingNotificationKey = useRef<string>(`editing-${Date.now()}`);
|
||
const [isHandleEdit, setIsHandleEdit] = React.useState(false);
|
||
|
||
// 使用 ref 存储 handleTestExport 避免循环依赖
|
||
const handleTestExportRef = useRef<(() => Promise<any>) | null>(null);
|
||
|
||
// 导出进度回调处理
|
||
const handleExportProgress = useCallback((progressData: {
|
||
status: 'processing' | 'completed' | 'failed';
|
||
percentage: number;
|
||
message: string;
|
||
stage?: string;
|
||
taskId?: string;
|
||
}) => {
|
||
console.log('📊 导出进度更新:', progressData);
|
||
setExportProgress(progressData);
|
||
|
||
// 根据状态显示不同的通知 - 已注释
|
||
/*
|
||
if (progressData.status === 'processing') {
|
||
notification.info({
|
||
message: '导出进度',
|
||
description: `${progressData.message} (${progressData.percentage}%)`,
|
||
placement: 'topRight',
|
||
duration: 2,
|
||
key: 'export-progress'
|
||
});
|
||
} else if (progressData.status === 'completed') {
|
||
notification.success({
|
||
message: '导出成功',
|
||
description: progressData.message,
|
||
placement: 'topRight',
|
||
duration: 5,
|
||
key: 'export-progress'
|
||
});
|
||
} else if (progressData.status === 'failed') {
|
||
notification.error({
|
||
message: '导出失败',
|
||
description: progressData.message,
|
||
placement: 'topRight',
|
||
duration: 8,
|
||
key: 'export-progress'
|
||
});
|
||
}
|
||
*/
|
||
}, []);
|
||
// 处理编辑计划生成完成的回调
|
||
const handleEditPlanGenerated = useCallback(() => {
|
||
console.log('🚀 handleEditPlanGenerated called, current ref:', isEditingInProgressRef.current);
|
||
|
||
// 防止重复调用 - 使用 ref 避免依赖项变化
|
||
if (isEditingInProgressRef.current) {
|
||
console.log('⚠️ 编辑已在进行中,跳过重复调用');
|
||
return;
|
||
}
|
||
|
||
console.log('✨ 编辑计划生成完成,开始AI剪辑');
|
||
setIsHandleEdit(true);
|
||
setEditingStatus('idle');
|
||
// setIsEditingInProgress(true); // 已移除该状态变量
|
||
isEditingInProgressRef.current = true;
|
||
|
||
// 改为调用测试剪辑计划导出按钮方法
|
||
// aiEditingButtonRef.current?.handleAIEditing();
|
||
// 使用 ref 调用避免循环依赖
|
||
setTimeout(() => {
|
||
handleTestExportRef.current?.();
|
||
}, 0);
|
||
editingNotificationKey.current = `editing-${Date.now()}`;
|
||
showEditingNotification({
|
||
description: 'Performing intelligent editing...',
|
||
successDescription: 'Editing successful',
|
||
timeoutDescription: 'Editing failed. Please click the scissors button to go to the intelligent editing platform.',
|
||
timeout: 8 * 60 * 1000,
|
||
key: editingNotificationKey.current,
|
||
onFail: () => {
|
||
console.log('❌ onFail callback triggered - Editing failed, retrying...');
|
||
// 清缓存 生成计划 视频重新分析
|
||
localStorage.removeItem(`isLoaded_plan_${episodeId}`);
|
||
|
||
// 先销毁当前通知
|
||
if (editingNotificationKey.current) {
|
||
notification.destroy(editingNotificationKey.current);
|
||
}
|
||
|
||
// 重新生成 iframeAiEditingKey 触发重新渲染
|
||
// setIframeAiEditingKey(`iframe-ai-editing-${Date.now()}`);
|
||
|
||
// 延时200ms后显示重试通知,确保之前的通知已销毁
|
||
setTimeout(() => {
|
||
editingNotificationKey.current = `editing-${Date.now()}`;
|
||
showEditingNotification({
|
||
description: 'Retry intelligent editing...',
|
||
successDescription: 'Editing successful',
|
||
timeoutDescription: 'Editing failed. Please click the scissors button to go to the intelligent editing platform.',
|
||
timeout: 5 * 60 * 1000, // 5分钟超时
|
||
key: editingNotificationKey.current,
|
||
onFail: () => {
|
||
console.log('Editing retry failed');
|
||
// 清缓存
|
||
localStorage.removeItem(`isLoaded_plan_${episodeId}`);
|
||
// 5秒后关闭通知并设置错误状态
|
||
setTimeout(() => {
|
||
setEditingStatus('error');
|
||
setIsEditingInProgress(false); // 重置编辑状态
|
||
isEditingInProgressRef.current = false; // 重置 ref
|
||
if (editingNotificationKey.current) {
|
||
notification.destroy(editingNotificationKey.current);
|
||
}
|
||
}, 5000);
|
||
}
|
||
});
|
||
}, 200);
|
||
}
|
||
});
|
||
}, [episodeId]); // handleTestExport 在内部调用,无需作为依赖
|
||
|
||
/** 处理导出失败 */
|
||
const handleExportFailed = useCallback(() => {
|
||
console.log('Export failed, setting error status');
|
||
setEditingStatus('error');
|
||
// setIsEditingInProgress(false); // 已移除该状态变量
|
||
isEditingInProgressRef.current = false;
|
||
|
||
// 销毁当前编辑通知
|
||
if (editingNotificationKey.current) {
|
||
notification.destroy(editingNotificationKey.current);
|
||
}
|
||
}, []);
|
||
|
||
// 使用自定义 hooks 管理状态
|
||
const {
|
||
taskObject,
|
||
scriptData,
|
||
isLoading,
|
||
currentSketchIndex,
|
||
currentLoadingText,
|
||
setCurrentSketchIndex,
|
||
isPauseWorkFlow,
|
||
mode,
|
||
setIsPauseWorkFlow,
|
||
setAnyAttribute,
|
||
applyScript,
|
||
fallbackToStep,
|
||
originalText,
|
||
showGotoCutButton,
|
||
generateEditPlan,
|
||
handleRetryVideo,
|
||
isShowAutoEditing
|
||
} = useWorkflowData({
|
||
onEditPlanGenerated: handleEditPlanGenerated,
|
||
editingStatus: editingStatus,
|
||
onExportFailed: handleExportFailed
|
||
});
|
||
|
||
const {
|
||
isVideoPlaying,
|
||
toggleVideoPlay,
|
||
} = usePlaybackControls(taskObject.videos.data, taskObject.currentStage);
|
||
|
||
useEffect(() => {
|
||
console.log('changedIndex_work-flow', currentSketchIndex, taskObject);
|
||
}, [currentSketchIndex, taskObject]);
|
||
|
||
// 监听粗剪是否完成
|
||
useEffect(() => {
|
||
console.log('🎬 final video useEffect triggered:', {
|
||
finalUrl: taskObject.final.url,
|
||
notificationKey: editingNotificationKey.current,
|
||
isHandleEdit
|
||
});
|
||
|
||
if (taskObject.final.url && editingNotificationKey.current && isHandleEdit) {
|
||
console.log('🎉 显示编辑完成通知');
|
||
// 更新通知状态为完成
|
||
showEditingNotification({
|
||
isCompleted: true,
|
||
description: 'Performing intelligent editing...',
|
||
successDescription: 'Editing successful',
|
||
timeoutDescription: 'Editing failed, please try again',
|
||
timeout: 5 * 60 * 1000,
|
||
key: editingNotificationKey.current,
|
||
onComplete: () => {
|
||
console.log('Editing successful');
|
||
localStorage.setItem(`isLoaded_plan_${episodeId}`, 'true');
|
||
setEditingStatus('success');
|
||
setIsEditingInProgress(false); // 重置编辑状态
|
||
isEditingInProgressRef.current = false; // 重置 ref
|
||
// 3秒后关闭通知
|
||
setTimeout(() => {
|
||
if (editingNotificationKey.current) {
|
||
notification.destroy(editingNotificationKey.current);
|
||
}
|
||
}, 3000);
|
||
},
|
||
});
|
||
}
|
||
}, [taskObject.final, isHandleEdit, episodeId]);
|
||
|
||
const handleEditModalOpen = useCallback((tab: string) => {
|
||
setActiveEditTab(tab);
|
||
setIsEditModalOpen(true);
|
||
}, []);
|
||
|
||
// AI剪辑回调函数
|
||
const handleAIEditingComplete = useCallback((finalVideoUrl: string) => {
|
||
console.log('🎉 AI剪辑完成,最终视频URL:', finalVideoUrl);
|
||
|
||
// 更新任务对象的最终视频状态
|
||
setAnyAttribute('final', {
|
||
url: finalVideoUrl,
|
||
note: 'ai_edited'
|
||
});
|
||
|
||
// 切换到最终视频阶段
|
||
setAnyAttribute('currentStage', 'final_video');
|
||
|
||
// setAiEditingInProgress(false); // 已移除该状态变量
|
||
}, [setAnyAttribute]);
|
||
|
||
const handleAIEditingError = useCallback((error: string) => {
|
||
console.error('❌ AI剪辑失败:', error);
|
||
// 这里可以显示错误提示
|
||
// setAiEditingInProgress(false); // 已移除该状态变量
|
||
}, []);
|
||
|
||
// 测试导出接口的处理函数(使用封装的导出服务)
|
||
const handleTestExport = useCallback(async () => {
|
||
console.log('🧪 开始测试导出接口...');
|
||
console.log('📊 当前taskObject状态:', {
|
||
currentStage: taskObject.currentStage,
|
||
videosCount: taskObject.videos?.data?.length || 0,
|
||
completedVideos: taskObject.videos?.data?.filter(v => v.video_status === 1).length || 0
|
||
});
|
||
|
||
try {
|
||
// 使用封装的导出服务,传递进度回调
|
||
const result = await exportVideoWithRetry(episodeId, taskObject, handleExportProgress);
|
||
console.log('🎉 导出服务完成,结果:', result);
|
||
return result;
|
||
} catch (error) {
|
||
console.error('❌ 导出服务失败:', error);
|
||
throw error;
|
||
}
|
||
}, [episodeId, taskObject, handleExportProgress]);
|
||
|
||
// 将 handleTestExport 赋值给 ref
|
||
React.useEffect(() => {
|
||
handleTestExportRef.current = handleTestExport;
|
||
}, [handleTestExport]);
|
||
|
||
|
||
|
||
|
||
|
||
|
||
// iframe智能剪辑回调函数 - 已注释
|
||
/*
|
||
const handleIframeAIEditingComplete = useCallback((result: any) => {
|
||
console.log('🎉 iframe AI剪辑完成,结果:', result);
|
||
|
||
// 保存剪辑结果
|
||
setAiEditingResult(result);
|
||
|
||
// 更新任务对象的最终视频状态
|
||
setAnyAttribute('final', {
|
||
url: result.videoUrl,
|
||
note: 'ai_edited_iframe'
|
||
});
|
||
|
||
// 切换到最终视频阶段
|
||
setAnyAttribute('currentStage', 'final_video');
|
||
|
||
// setAiEditingInProgress(false); // 已移除该状态变量
|
||
}, [setAnyAttribute]);
|
||
*/
|
||
|
||
/*
|
||
const handleIframeAIEditingError = useCallback((error: string) => {
|
||
console.error('❌ iframe AI剪辑失败:', error);
|
||
// setAiEditingInProgress(false); // 已移除该状态变量
|
||
}, []);
|
||
*/
|
||
|
||
/*
|
||
const handleIframeAIEditingProgress = useCallback((progress: number, message: string) => {
|
||
console.log(`📊 AI剪辑进度: ${progress}% - ${message}`);
|
||
// setAiEditingInProgress(true); // 已移除该状态变量
|
||
}, []);
|
||
*/
|
||
|
||
return (
|
||
<div className="w-full overflow-hidden h-full px-[1rem] pb-[1rem]">
|
||
<div className="w-full h-full">
|
||
<div className="splashContainer-otuV_A">
|
||
<div className="content-vPGYx8">
|
||
<div className="info-UUGkPJ">
|
||
<TaskInfo
|
||
taskObject={taskObject}
|
||
currentLoadingText={currentLoadingText}
|
||
roles={taskObject.roles.data}
|
||
isPauseWorkFlow={isPauseWorkFlow}
|
||
showGotoCutButton={showGotoCutButton || editingStatus !== 'idle'}
|
||
onGotoCut={generateEditPlan}
|
||
setIsPauseWorkFlow={setIsPauseWorkFlow}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="media-Ocdu1O rounded-lg">
|
||
<div
|
||
className="videoContainer-qteKNi"
|
||
ref={containerRef}
|
||
>
|
||
<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}>
|
||
<MediaViewer
|
||
taskObject={taskObject}
|
||
scriptData={scriptData}
|
||
currentSketchIndex={currentSketchIndex}
|
||
isVideoPlaying={isVideoPlaying}
|
||
onEditModalOpen={handleEditModalOpen}
|
||
onToggleVideoPlay={toggleVideoPlay}
|
||
setIsPauseWorkFlow={setIsPauseWorkFlow}
|
||
setAnyAttribute={setAnyAttribute}
|
||
isPauseWorkFlow={isPauseWorkFlow}
|
||
applyScript={applyScript}
|
||
mode={mode}
|
||
onOpenChat={() => setIsSmartChatBoxOpen(true)}
|
||
setVideoPreview={(url, id) => {
|
||
setPreviewVideoUrl(url);
|
||
setPreviewVideoId(id);
|
||
}}
|
||
showGotoCutButton={showGotoCutButton || editingStatus !== 'idle'}
|
||
onGotoCut={generateEditPlan}
|
||
isSmartChatBoxOpen={isSmartChatBoxOpen}
|
||
onRetryVideo={(video_id) => handleRetryVideo(video_id)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
{taskObject.currentStage !== 'script' && (
|
||
<div className="h-[123px] w-[calc((100vh-6rem-200px)/9*16)]">
|
||
<ThumbnailGrid
|
||
isDisabledFocus={isEditModalOpen || isPauseWorkFlow || isFocusChatInput}
|
||
taskObject={taskObject}
|
||
currentSketchIndex={currentSketchIndex}
|
||
onSketchSelect={setCurrentSketchIndex}
|
||
onRetryVideo={handleRetryVideo}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* AI剪辑按钮 - 已注释,不加载iframe */}
|
||
{/*
|
||
{
|
||
isShowAutoEditing && (
|
||
<div className="fixed right-[2rem] top-[8rem] z-[49]">
|
||
<Tooltip title="AI智能剪辑" placement="left">
|
||
<AIEditingIframeButton
|
||
key={iframeAiEditingKey}
|
||
ref={aiEditingButtonRef}
|
||
projectId={episodeId}
|
||
token={localStorage.getItem("token") || ""}
|
||
userId={userId.toString()}
|
||
size="md"
|
||
onComplete={handleIframeAIEditingComplete}
|
||
onError={handleIframeAIEditingError}
|
||
onProgress={handleIframeAIEditingProgress}
|
||
autoStart={process.env.NODE_ENV !== 'development'}
|
||
/>
|
||
</Tooltip>
|
||
</div>
|
||
)
|
||
}
|
||
*/}
|
||
|
||
{/* 导出进度显示 - 已注释 */}
|
||
{/*
|
||
{exportProgress && exportProgress.status === 'processing' && (
|
||
<div className="fixed right-[1rem] bottom-[20rem] z-[49]">
|
||
<div className="backdrop-blur-lg bg-black/30 border border-white/20 rounded-lg p-4 max-w-xs">
|
||
<div className="text-white text-sm mb-2">
|
||
导出进度: {exportProgress.percentage}%
|
||
</div>
|
||
<div className="w-full bg-gray-700 rounded-full h-2 mb-2">
|
||
<div
|
||
className="bg-blue-500 h-2 rounded-full transition-all duration-300"
|
||
style={{ width: `${exportProgress.percentage}%` }}
|
||
/>
|
||
</div>
|
||
<div className="text-gray-300 text-xs">
|
||
{exportProgress.message}
|
||
{exportProgress.stage && ` (${exportProgress.stage})`}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
*/}
|
||
|
||
{/* 测试导出接口按钮 - 隐藏显示(仍可通过逻辑调用) */}
|
||
<div
|
||
className="fixed right-[1rem] bottom-[16rem] z-[49]"
|
||
style={{ display: 'none' }}
|
||
>
|
||
<Tooltip title="测试剪辑计划导出接口" placement="left">
|
||
<GlassIconButton
|
||
icon={TestTube}
|
||
size='md'
|
||
onClick={handleTestExport}
|
||
className="backdrop-blur-lg"
|
||
/>
|
||
</Tooltip>
|
||
</div>
|
||
|
||
{/* 智能对话按钮 */}
|
||
<div
|
||
className="fixed right-[1rem] bottom-[10rem] z-[49]"
|
||
>
|
||
<Tooltip title="Open chat" placement="left">
|
||
<GlassIconButton
|
||
icon={Bot}
|
||
size='md'
|
||
onClick={() => setIsSmartChatBoxOpen(true)}
|
||
className="backdrop-blur-lg"
|
||
/>
|
||
</Tooltip>
|
||
</div>
|
||
|
||
{/* 智能对话弹窗 */}
|
||
<Drawer
|
||
width="25%"
|
||
placement="right"
|
||
closable={false}
|
||
maskClosable={false}
|
||
open={isSmartChatBoxOpen}
|
||
getContainer={false}
|
||
autoFocus={false}
|
||
mask={false}
|
||
zIndex={49}
|
||
rootClassName="outline-none"
|
||
className="backdrop-blur-lg bg-black/30 border border-white/20 shadow-xl"
|
||
style={{
|
||
backgroundColor: 'transparent',
|
||
borderBottomLeftRadius: 10,
|
||
borderTopLeftRadius: 10,
|
||
overflow: 'hidden',
|
||
}}
|
||
styles={{
|
||
body: {
|
||
backgroundColor: 'transparent',
|
||
padding: 0,
|
||
},
|
||
}}
|
||
onClose={() => setIsSmartChatBoxOpen(false)}
|
||
>
|
||
<SmartChatBox
|
||
isSmartChatBoxOpen={isSmartChatBoxOpen}
|
||
setIsSmartChatBoxOpen={setIsSmartChatBoxOpen}
|
||
projectId={episodeId}
|
||
userId={userId}
|
||
previewVideoUrl={previewVideoUrl}
|
||
previewVideoId={previewVideoId}
|
||
setIsFocusChatInput={setIsFocusChatInput}
|
||
onClearPreview={() => {
|
||
setPreviewVideoUrl(null);
|
||
setPreviewVideoId(null);
|
||
}}
|
||
aiEditingResult={aiEditingResult}
|
||
/>
|
||
</Drawer>
|
||
|
||
<EditModal
|
||
isOpen={isEditModalOpen}
|
||
activeEditTab={activeEditTab}
|
||
onClose={() => {
|
||
SaveEditUseCase.clearData();
|
||
setIsEditModalOpen(false)
|
||
}}
|
||
taskObject={taskObject}
|
||
currentSketchIndex={currentSketchIndex}
|
||
roles={taskObject.roles.data}
|
||
setIsPauseWorkFlow={setIsPauseWorkFlow}
|
||
isPauseWorkFlow={isPauseWorkFlow}
|
||
fallbackToStep={fallbackToStep}
|
||
originalText={originalText}
|
||
/>
|
||
</div>
|
||
)
|
||
});
|
||
|
||
export default WorkFlow;
|