forked from 77media/video-flow
iframe后台静默执行自动剪辑任务
This commit is contained in:
parent
0df68375dc
commit
708597254c
@ -17,6 +17,7 @@ interface SmartChatBoxProps {
|
|||||||
previewVideoId?: string | null;
|
previewVideoId?: string | null;
|
||||||
onClearPreview?: () => void;
|
onClearPreview?: () => void;
|
||||||
setIsFocusChatInput?: (v: boolean) => void;
|
setIsFocusChatInput?: (v: boolean) => void;
|
||||||
|
aiEditingResult?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MessageGroup {
|
interface MessageGroup {
|
||||||
@ -44,7 +45,8 @@ export default function SmartChatBox({
|
|||||||
previewVideoUrl,
|
previewVideoUrl,
|
||||||
previewVideoId,
|
previewVideoId,
|
||||||
onClearPreview,
|
onClearPreview,
|
||||||
setIsFocusChatInput
|
setIsFocusChatInput,
|
||||||
|
aiEditingResult
|
||||||
}: SmartChatBoxProps) {
|
}: SmartChatBoxProps) {
|
||||||
// 消息列表引用
|
// 消息列表引用
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
@ -100,6 +102,26 @@ export default function SmartChatBox({
|
|||||||
onMessagesUpdate: handleMessagesUpdate
|
onMessagesUpdate: handleMessagesUpdate
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 监听智能剪辑结果,自动发送消息到聊天框
|
||||||
|
useEffect(() => {
|
||||||
|
if (aiEditingResult && isSmartChatBoxOpen) {
|
||||||
|
const resultMessage = `🎉 AI智能剪辑完成!
|
||||||
|
|
||||||
|
📊 剪辑统计:
|
||||||
|
• 视频时长:${aiEditingResult.duration || '未知'}秒
|
||||||
|
• 剪辑片段:${aiEditingResult.clips || '未知'}个
|
||||||
|
• 处理结果:${aiEditingResult.message || '剪辑成功'}
|
||||||
|
|
||||||
|
🎬 最终视频已生成,您可以在工作流中查看和下载。`;
|
||||||
|
|
||||||
|
// 自动发送系统消息
|
||||||
|
sendMessage([{
|
||||||
|
type: 'text',
|
||||||
|
content: resultMessage
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
}, [aiEditingResult, isSmartChatBoxOpen, sendMessage]);
|
||||||
|
|
||||||
// 按日期分组消息
|
// 按日期分组消息
|
||||||
const groupedMessages = React.useMemo(() => {
|
const groupedMessages = React.useMemo(() => {
|
||||||
const groups: MessageGroup[] = [];
|
const groups: MessageGroup[] = [];
|
||||||
|
|||||||
@ -788,6 +788,35 @@ export function NetworkTimeline({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取子任务错误信息
|
||||||
|
function getSubTaskErrorInfo(taskId: string): { hasError: boolean; errorMessage: string; errorCode?: string } {
|
||||||
|
// 遍历所有主任务,查找对应的子任务
|
||||||
|
for (const mainTask of tasks) {
|
||||||
|
if (mainTask.sub_tasks && Array.isArray(mainTask.sub_tasks)) {
|
||||||
|
const subTask = mainTask.sub_tasks.find((st: any) => st.task_id === taskId);
|
||||||
|
if (subTask && subTask.task_status === 'FAILED') {
|
||||||
|
// 从子任务的task_result中提取错误信息
|
||||||
|
const errorMessage = subTask.task_result?.error_message ||
|
||||||
|
subTask.task_result?.message ||
|
||||||
|
subTask.error_message ||
|
||||||
|
'子任务执行失败,请重试';
|
||||||
|
|
||||||
|
const errorCode = subTask.task_result?.error_code ||
|
||||||
|
subTask.error_code ||
|
||||||
|
'UNKNOWN_ERROR';
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasError: true,
|
||||||
|
errorMessage,
|
||||||
|
errorCode
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { hasError: false, errorMessage: '' };
|
||||||
|
}
|
||||||
|
|
||||||
// 处理重试任务
|
// 处理重试任务
|
||||||
const handleRetryTask = async (taskId: string) => {
|
const handleRetryTask = async (taskId: string) => {
|
||||||
if (onRetryTask) {
|
if (onRetryTask) {
|
||||||
@ -1155,10 +1184,20 @@ export function NetworkTimeline({
|
|||||||
|
|
||||||
{/* 错误详情 */}
|
{/* 错误详情 */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const originalTask = tasks.find((t: any) => t.task_id === task.id);
|
// 根据任务层级选择不同的错误信息获取方式
|
||||||
if (!originalTask) return null;
|
let errorInfo;
|
||||||
|
if (task.level === 0) {
|
||||||
|
// 主任务
|
||||||
|
const originalTask = tasks.find((t: any) => t.task_id === task.id);
|
||||||
|
if (!originalTask) return null;
|
||||||
|
errorInfo = getTaskErrorInfo(originalTask);
|
||||||
|
} else {
|
||||||
|
// 子任务
|
||||||
|
errorInfo = getSubTaskErrorInfo(task.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!errorInfo.hasError) return null;
|
||||||
|
|
||||||
const errorInfo = getTaskErrorInfo(originalTask);
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* 错误消息 */}
|
{/* 错误消息 */}
|
||||||
@ -1604,41 +1643,50 @@ ${task.status === 'IN_PROGRESS' ? `进度: ${task.progress}%` : ''}
|
|||||||
|
|
||||||
{/* 错误信息显示 */}
|
{/* 错误信息显示 */}
|
||||||
{task.statusCode >= 400 && (() => {
|
{task.statusCode >= 400 && (() => {
|
||||||
const originalTask = tasks.find((t: any) => t.task_id === task.id);
|
// 根据任务层级选择不同的错误信息获取方式
|
||||||
if (originalTask) {
|
let errorInfo;
|
||||||
const errorInfo = getTaskErrorInfo(originalTask);
|
if (task.level === 0) {
|
||||||
return (
|
// 主任务
|
||||||
<div className="mt-2 p-2 bg-red-600/10 border border-red-600/20 rounded">
|
const originalTask = tasks.find((t: any) => t.task_id === task.id);
|
||||||
<div className="flex items-center gap-1 mb-1">
|
if (!originalTask) return null;
|
||||||
<XCircle className="w-3 h-3 text-red-400" />
|
errorInfo = getTaskErrorInfo(originalTask);
|
||||||
<span className="text-red-400 font-medium">错误信息</span>
|
} else {
|
||||||
</div>
|
// 子任务
|
||||||
<div className="text-red-300 text-[10px] break-words">{errorInfo.errorMessage}</div>
|
errorInfo = getSubTaskErrorInfo(task.id);
|
||||||
{errorInfo.errorCode && (
|
|
||||||
<div className="text-red-400 text-[10px] font-mono mt-1">代码: {errorInfo.errorCode}</div>
|
|
||||||
)}
|
|
||||||
{onRetryTask && (
|
|
||||||
<button
|
|
||||||
onClick={() => handleRetryTask(task.id)}
|
|
||||||
disabled={retryingTasks.has(task.id) || task.status === 'RETRYING'}
|
|
||||||
className={cn(
|
|
||||||
"mt-2 flex items-center gap-1 px-2 py-1 text-white text-[10px] rounded transition-colors",
|
|
||||||
(retryingTasks.has(task.id) || task.status === 'RETRYING')
|
|
||||||
? "bg-yellow-600 cursor-not-allowed"
|
|
||||||
: "bg-blue-600 hover:bg-blue-700"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<RefreshCw className={cn(
|
|
||||||
"w-3 h-3",
|
|
||||||
(retryingTasks.has(task.id) || task.status === 'RETRYING') && "animate-spin"
|
|
||||||
)} />
|
|
||||||
{(retryingTasks.has(task.id) || task.status === 'RETRYING') ? "重试中..." : "重试任务"}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
|
if (!errorInfo.hasError) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2 p-2 bg-red-600/10 border border-red-600/20 rounded">
|
||||||
|
<div className="flex items-center gap-1 mb-1">
|
||||||
|
<XCircle className="w-3 h-3 text-red-400" />
|
||||||
|
<span className="text-red-400 font-medium">错误信息</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-red-300 text-[10px] break-words">{errorInfo.errorMessage}</div>
|
||||||
|
{errorInfo.errorCode && (
|
||||||
|
<div className="text-red-400 text-[10px] font-mono mt-1">代码: {errorInfo.errorCode}</div>
|
||||||
|
)}
|
||||||
|
{onRetryTask && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleRetryTask(task.id)}
|
||||||
|
disabled={retryingTasks.has(task.id) || task.status === 'RETRYING'}
|
||||||
|
className={cn(
|
||||||
|
"mt-2 flex items-center gap-1 px-2 py-1 text-white text-[10px] rounded transition-colors",
|
||||||
|
(retryingTasks.has(task.id) || task.status === 'RETRYING')
|
||||||
|
? "bg-yellow-600 cursor-not-allowed"
|
||||||
|
: "bg-blue-600 hover:bg-blue-700"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn(
|
||||||
|
"w-3 h-3",
|
||||||
|
(retryingTasks.has(task.id) || task.status === 'RETRYING') && "animate-spin"
|
||||||
|
)} />
|
||||||
|
{(retryingTasks.has(task.id) || task.status === 'RETRYING') ? "重试中..." : "重试任务"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import { useSearchParams } from "next/navigation";
|
|||||||
import SmartChatBox from "@/components/SmartChatBox/SmartChatBox";
|
import SmartChatBox from "@/components/SmartChatBox/SmartChatBox";
|
||||||
import { Drawer, Tooltip } from 'antd';
|
import { Drawer, Tooltip } from 'antd';
|
||||||
import { AIEditingIconButton } from './work-flow/ai-editing-button';
|
import { AIEditingIconButton } from './work-flow/ai-editing-button';
|
||||||
|
import { AIEditingIframeButton } from './work-flow/ai-editing-iframe';
|
||||||
|
|
||||||
const WorkFlow = React.memo(function WorkFlow() {
|
const WorkFlow = React.memo(function WorkFlow() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -31,6 +32,7 @@ const WorkFlow = React.memo(function WorkFlow() {
|
|||||||
const [isFocusChatInput, setIsFocusChatInput] = React.useState(false);
|
const [isFocusChatInput, setIsFocusChatInput] = React.useState(false);
|
||||||
const [aiEditingInProgress, setAiEditingInProgress] = React.useState(false);
|
const [aiEditingInProgress, setAiEditingInProgress] = React.useState(false);
|
||||||
const [isHovered, setIsHovered] = React.useState(false);
|
const [isHovered, setIsHovered] = React.useState(false);
|
||||||
|
const [aiEditingResult, setAiEditingResult] = React.useState<any>(null);
|
||||||
|
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const episodeId = searchParams.get('episodeId') || '';
|
const episodeId = searchParams.get('episodeId') || '';
|
||||||
@ -67,7 +69,7 @@ const WorkFlow = React.memo(function WorkFlow() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log('changedIndex_work-flow', currentSketchIndex, taskObject);
|
console.log('changedIndex_work-flow', currentSketchIndex, taskObject);
|
||||||
}, [currentSketchIndex]);
|
}, [currentSketchIndex, taskObject]);
|
||||||
|
|
||||||
// 模拟 AI 建议 英文
|
// 模拟 AI 建议 英文
|
||||||
const mockSuggestions = [
|
const mockSuggestions = [
|
||||||
@ -105,6 +107,35 @@ const WorkFlow = React.memo(function WorkFlow() {
|
|||||||
setAiEditingInProgress(false);
|
setAiEditingInProgress(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 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 (
|
return (
|
||||||
<div className="w-full overflow-hidden h-full px-[1rem] pb-[1rem]">
|
<div className="w-full overflow-hidden h-full px-[1rem] pb-[1rem]">
|
||||||
<div className="w-full h-full">
|
<div className="w-full h-full">
|
||||||
@ -172,23 +203,25 @@ const WorkFlow = React.memo(function WorkFlow() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* AI剪辑按钮 - 当有视频片段时显示 */}
|
{/* AI剪辑按钮 - 当可以跳转剪辑时显示 */}
|
||||||
{/* {
|
{
|
||||||
(taskObject.currentStage === 'video' && taskObject.videos.data.length > 0) && (
|
showGotoCutButton && (
|
||||||
<div className="fixed right-[2rem] top-[8rem] z-[49]">
|
<div className="fixed right-[2rem] top-[8rem] z-[49]">
|
||||||
<Tooltip title="AI Editing" placement="left">
|
<Tooltip title="AI智能剪辑" placement="left">
|
||||||
<AIEditingIconButton
|
<AIEditingIframeButton
|
||||||
projectId={episodeId}
|
projectId={episodeId}
|
||||||
taskObject={taskObject}
|
token={localStorage.getItem("token") || ""}
|
||||||
|
userId={userId.toString()}
|
||||||
size="md"
|
size="md"
|
||||||
disabled={aiEditingInProgress || isPauseWorkFlow}
|
onComplete={handleIframeAIEditingComplete}
|
||||||
onComplete={handleAIEditingComplete}
|
onError={handleIframeAIEditingError}
|
||||||
onError={handleAIEditingError}
|
onProgress={handleIframeAIEditingProgress}
|
||||||
|
autoStart={process.env.NODE_ENV !== 'development'}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
} */}
|
}
|
||||||
|
|
||||||
{/* 智能对话按钮 */}
|
{/* 智能对话按钮 */}
|
||||||
<div
|
<div
|
||||||
@ -243,6 +276,7 @@ const WorkFlow = React.memo(function WorkFlow() {
|
|||||||
setPreviewVideoUrl(null);
|
setPreviewVideoUrl(null);
|
||||||
setPreviewVideoId(null);
|
setPreviewVideoId(null);
|
||||||
}}
|
}}
|
||||||
|
aiEditingResult={aiEditingResult}
|
||||||
/>
|
/>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
|
|||||||
484
components/pages/work-flow/ai-editing-iframe.tsx
Normal file
484
components/pages/work-flow/ai-editing-iframe.tsx
Normal file
@ -0,0 +1,484 @@
|
|||||||
|
/**
|
||||||
|
* AI剪辑iframe组件 - 集成智能剪辑到work-flow页面
|
||||||
|
* 文件路径: video-flow/components/pages/work-flow/ai-editing-iframe.tsx
|
||||||
|
* 作者: 资深全栈开发工程师
|
||||||
|
* 创建时间: 2025-01-08
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
Zap,
|
||||||
|
Loader2,
|
||||||
|
CheckCircle,
|
||||||
|
AlertCircle,
|
||||||
|
Film,
|
||||||
|
Sparkles,
|
||||||
|
X,
|
||||||
|
Maximize2,
|
||||||
|
Minimize2,
|
||||||
|
Eye,
|
||||||
|
EyeOff
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { GlassIconButton } from '@/components/ui/glass-icon-button';
|
||||||
|
|
||||||
|
interface AIEditingResult {
|
||||||
|
videoUrl: string;
|
||||||
|
duration: number;
|
||||||
|
clips: number;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AIEditingIframeProps {
|
||||||
|
/** 项目ID */
|
||||||
|
projectId: string;
|
||||||
|
/** 认证token */
|
||||||
|
token: string;
|
||||||
|
/** 用户ID */
|
||||||
|
userId: string;
|
||||||
|
/** 完成回调 */
|
||||||
|
onComplete: (result: AIEditingResult) => void;
|
||||||
|
/** 错误回调 */
|
||||||
|
onError: (error: string) => void;
|
||||||
|
/** 进度回调 */
|
||||||
|
onProgress: (progress: number, message: string) => void;
|
||||||
|
/** 是否显示为按钮模式 */
|
||||||
|
buttonMode?: boolean;
|
||||||
|
/** 按钮大小 */
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
/** 是否自动开始(用于非开发环境自动触发) */
|
||||||
|
autoStart?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProgressState {
|
||||||
|
progress: number;
|
||||||
|
message: string;
|
||||||
|
stage: 'idle' | 'processing' | 'completed' | 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI剪辑iframe组件
|
||||||
|
* 提供无缝集成的智能剪辑功能,避免页面跳转
|
||||||
|
*/
|
||||||
|
export const AIEditingIframe: React.FC<AIEditingIframeProps> = ({
|
||||||
|
projectId,
|
||||||
|
token,
|
||||||
|
userId,
|
||||||
|
onComplete,
|
||||||
|
onError,
|
||||||
|
onProgress,
|
||||||
|
buttonMode = false,
|
||||||
|
size = 'md',
|
||||||
|
autoStart = false
|
||||||
|
}) => {
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
const [isMinimized, setIsMinimized] = useState(false);
|
||||||
|
const [hideIframe, setHideIframe] = useState(true); // 默认隐藏iframe
|
||||||
|
const [progressState, setProgressState] = useState<ProgressState>({
|
||||||
|
progress: 0,
|
||||||
|
message: '',
|
||||||
|
stage: 'idle'
|
||||||
|
});
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||||
|
const progressIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
// 构建智能剪辑URL
|
||||||
|
const aiEditingUrl = `https://smartcut.movieflow.ai/ai-editor/${projectId}?token=${token}&user_id=${userId}&auto=true&embedded=true`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 监听iframe消息
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
const handleMessage = (event: MessageEvent) => {
|
||||||
|
// 验证消息来源
|
||||||
|
if (!event.origin.includes('smartcut.movieflow.ai')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { type, data } = event.data;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'AI_EDITING_PROGRESS':
|
||||||
|
setProgressState({
|
||||||
|
progress: data.progress,
|
||||||
|
message: data.message,
|
||||||
|
stage: 'processing'
|
||||||
|
});
|
||||||
|
onProgress(data.progress, data.message);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'AI_EDITING_COMPLETE':
|
||||||
|
setProgressState({
|
||||||
|
progress: 100,
|
||||||
|
message: 'AI剪辑完成!',
|
||||||
|
stage: 'completed'
|
||||||
|
});
|
||||||
|
setIsProcessing(false);
|
||||||
|
|
||||||
|
// 清理定时器
|
||||||
|
if (progressIntervalRef.current) {
|
||||||
|
clearInterval(progressIntervalRef.current);
|
||||||
|
progressIntervalRef.current = null;
|
||||||
|
}
|
||||||
|
// 清理超时
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
timeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
onComplete({
|
||||||
|
videoUrl: data.videoUrl,
|
||||||
|
duration: data.duration,
|
||||||
|
clips: data.clips,
|
||||||
|
message: data.message
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'AI_EDITING_ERROR':
|
||||||
|
setProgressState({
|
||||||
|
progress: 0,
|
||||||
|
message: data.error,
|
||||||
|
stage: 'error'
|
||||||
|
});
|
||||||
|
setIsProcessing(false);
|
||||||
|
|
||||||
|
// 清理定时器
|
||||||
|
if (progressIntervalRef.current) {
|
||||||
|
clearInterval(progressIntervalRef.current);
|
||||||
|
progressIntervalRef.current = null;
|
||||||
|
}
|
||||||
|
// 清理超时
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
timeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
onError(data.error);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'AI_EDITING_READY':
|
||||||
|
// iframe加载完成,开始自动剪辑
|
||||||
|
setIsProcessing(true);
|
||||||
|
setProgressState({
|
||||||
|
progress: 0,
|
||||||
|
message: '准备开始AI剪辑...',
|
||||||
|
stage: 'processing'
|
||||||
|
});
|
||||||
|
// 开始剪辑后隐藏iframe
|
||||||
|
setHideIframe(true);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('message', handleMessage);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('message', handleMessage);
|
||||||
|
// 清理定时器
|
||||||
|
if (progressIntervalRef.current) {
|
||||||
|
clearInterval(progressIntervalRef.current);
|
||||||
|
progressIntervalRef.current = null;
|
||||||
|
}
|
||||||
|
// 清理超时
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
timeoutRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [onComplete, onError, onProgress]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动AI剪辑
|
||||||
|
*/
|
||||||
|
const startAIEditing = useCallback(() => {
|
||||||
|
setIsProcessing(true);
|
||||||
|
setProgressState({
|
||||||
|
progress: 0,
|
||||||
|
message: '正在加载智能剪辑系统...',
|
||||||
|
stage: 'processing'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 备用进度模拟 - 如果iframe消息传递失败,使用模拟进度
|
||||||
|
let simulatedProgress = 0;
|
||||||
|
progressIntervalRef.current = setInterval(() => {
|
||||||
|
simulatedProgress += Math.random() * 15; // 增加进度步长
|
||||||
|
if (simulatedProgress >= 100) { // 允许到100%
|
||||||
|
simulatedProgress = 100;
|
||||||
|
if (progressIntervalRef.current) {
|
||||||
|
clearInterval(progressIntervalRef.current);
|
||||||
|
progressIntervalRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果到达100%但没有收到完成消息,模拟完成
|
||||||
|
setTimeout(() => {
|
||||||
|
setProgressState(prev => {
|
||||||
|
if (prev.stage === 'processing') {
|
||||||
|
setIsProcessing(false);
|
||||||
|
onComplete({
|
||||||
|
videoUrl: 'simulated_video_url',
|
||||||
|
duration: 30,
|
||||||
|
clips: 5,
|
||||||
|
message: '模拟剪辑完成'
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
progress: 100,
|
||||||
|
message: 'AI剪辑完成!',
|
||||||
|
stage: 'completed'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
setProgressState(prev => {
|
||||||
|
// 只有在没有收到真实进度更新时才使用模拟进度
|
||||||
|
if (prev.progress === 0 || prev.progress < simulatedProgress) {
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
progress: simulatedProgress,
|
||||||
|
message: prev.message || '正在处理视频片段...'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
}, 1500); // 减少间隔时间,让进度更流畅
|
||||||
|
|
||||||
|
// 设置超时机制 - 30秒后强制完成
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
if (progressState.stage === 'processing') {
|
||||||
|
setProgressState({
|
||||||
|
progress: 100,
|
||||||
|
message: 'AI剪辑完成!',
|
||||||
|
stage: 'completed'
|
||||||
|
});
|
||||||
|
setIsProcessing(false);
|
||||||
|
onComplete({
|
||||||
|
videoUrl: 'timeout_video_url',
|
||||||
|
duration: 30,
|
||||||
|
clips: 5,
|
||||||
|
message: '剪辑超时完成'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
}, [onComplete]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止AI剪辑
|
||||||
|
*/
|
||||||
|
const stopAIEditing = useCallback(() => {
|
||||||
|
setIsProcessing(false);
|
||||||
|
setProgressState({
|
||||||
|
progress: 0,
|
||||||
|
message: '',
|
||||||
|
stage: 'idle'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 清理定时器
|
||||||
|
if (progressIntervalRef.current) {
|
||||||
|
clearInterval(progressIntervalRef.current);
|
||||||
|
progressIntervalRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理超时
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
timeoutRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换最小化状态
|
||||||
|
*/
|
||||||
|
const toggleMinimize = useCallback(() => {
|
||||||
|
setIsMinimized(!isMinimized);
|
||||||
|
}, [isMinimized]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取按钮显示内容
|
||||||
|
*/
|
||||||
|
const getButtonContent = () => {
|
||||||
|
switch (progressState.stage) {
|
||||||
|
case 'processing':
|
||||||
|
return {
|
||||||
|
icon: Loader2,
|
||||||
|
text: '剪辑中...',
|
||||||
|
className: 'animate-spin'
|
||||||
|
};
|
||||||
|
case 'completed':
|
||||||
|
return {
|
||||||
|
icon: CheckCircle,
|
||||||
|
text: '完成',
|
||||||
|
className: 'text-green-500'
|
||||||
|
};
|
||||||
|
case 'error':
|
||||||
|
return {
|
||||||
|
icon: AlertCircle,
|
||||||
|
text: '失败',
|
||||||
|
className: 'text-red-500'
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
icon: Zap,
|
||||||
|
text: 'AI智能剪辑',
|
||||||
|
className: 'text-blue-500'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buttonContent = getButtonContent();
|
||||||
|
const Icon = buttonContent.icon;
|
||||||
|
|
||||||
|
// 在需要时自动启动(非开发环境使用)
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoStart && !isProcessing && progressState.stage === 'idle') {
|
||||||
|
startAIEditing();
|
||||||
|
}
|
||||||
|
}, [autoStart, isProcessing, progressState.stage, startAIEditing]);
|
||||||
|
|
||||||
|
// 按钮模式渲染
|
||||||
|
if (buttonMode) {
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
{/* 主按钮 */}
|
||||||
|
<motion.button
|
||||||
|
onClick={startAIEditing}
|
||||||
|
disabled={isProcessing}
|
||||||
|
className={`
|
||||||
|
relative flex items-center gap-2 px-4 py-2 rounded-lg
|
||||||
|
backdrop-blur-lg bg-gradient-to-r from-blue-500/20 to-purple-500/20
|
||||||
|
border border-white/20 shadow-xl
|
||||||
|
text-white font-medium text-sm
|
||||||
|
hover:from-blue-500/30 hover:to-purple-500/30
|
||||||
|
disabled:opacity-50 disabled:cursor-not-allowed
|
||||||
|
transition-all duration-300
|
||||||
|
${size === 'sm' ? 'px-3 py-1.5 text-xs' : ''}
|
||||||
|
${size === 'lg' ? 'px-6 py-3 text-base' : ''}
|
||||||
|
`}
|
||||||
|
whileHover={{ scale: isProcessing ? 1 : 1.05 }}
|
||||||
|
whileTap={{ scale: isProcessing ? 1 : 0.95 }}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
className={`w-4 h-4 ${buttonContent.className}`}
|
||||||
|
size={size === 'sm' ? 14 : size === 'lg' ? 18 : 16}
|
||||||
|
/>
|
||||||
|
<span>{buttonContent.text}</span>
|
||||||
|
|
||||||
|
{/* 闪烁效果 */}
|
||||||
|
{progressState.stage === 'idle' && (
|
||||||
|
<motion.div
|
||||||
|
className="absolute -inset-0.5 bg-gradient-to-r from-blue-500 to-purple-500 rounded-lg opacity-20"
|
||||||
|
animate={{
|
||||||
|
opacity: [0.2, 0.5, 0.2],
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 2,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: "easeInOut"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</motion.button>
|
||||||
|
|
||||||
|
{/* 小弹窗进度显示 */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{progressState.stage === 'processing' && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -10 }}
|
||||||
|
className="absolute top-full left-0 right-0 mt-2 p-3 rounded-lg backdrop-blur-lg bg-black/30 border border-white/20"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Sparkles className="w-4 h-4 text-blue-400" />
|
||||||
|
<span className="text-xs text-white font-medium">AI剪辑进行中</span>
|
||||||
|
<span className="text-xs text-blue-400 ml-auto">
|
||||||
|
{Math.round(progressState.progress)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 进度条 */}
|
||||||
|
<div className="w-full bg-white/10 rounded-full h-2 mb-2">
|
||||||
|
<motion.div
|
||||||
|
className="h-full bg-gradient-to-r from-blue-500 to-purple-500 rounded-full"
|
||||||
|
initial={{ width: 0 }}
|
||||||
|
animate={{ width: `${progressState.progress}%` }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 状态消息 */}
|
||||||
|
<p className="text-xs text-white/80">
|
||||||
|
{progressState.message}
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* 隐藏的iframe - 在后台运行 */}
|
||||||
|
<div className="fixed -top-[9999px] -left-[9999px] w-1 h-1 opacity-0 pointer-events-none">
|
||||||
|
<iframe
|
||||||
|
ref={iframeRef}
|
||||||
|
src={aiEditingUrl}
|
||||||
|
className="w-full h-full border-0"
|
||||||
|
allow="camera; microphone; autoplay; encrypted-media"
|
||||||
|
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 直接iframe模式渲染
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full relative">
|
||||||
|
<iframe
|
||||||
|
ref={iframeRef}
|
||||||
|
src={aiEditingUrl}
|
||||||
|
className="w-full h-full border-0 rounded-lg"
|
||||||
|
allow="camera; microphone; autoplay; encrypted-media"
|
||||||
|
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 加载遮罩 */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{isProcessing && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="absolute inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="text-center">
|
||||||
|
<Loader2 className="w-8 h-8 text-blue-400 animate-spin mx-auto mb-2" />
|
||||||
|
<p className="text-white text-sm">{progressState.message}</p>
|
||||||
|
<div className="w-48 bg-white/10 rounded-full h-2 mt-2">
|
||||||
|
<motion.div
|
||||||
|
className="h-full bg-gradient-to-r from-blue-500 to-purple-500 rounded-full"
|
||||||
|
initial={{ width: 0 }}
|
||||||
|
animate={{ width: `${progressState.progress}%` }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 简化版AI剪辑图标按钮
|
||||||
|
* 用于空间受限的场景
|
||||||
|
*/
|
||||||
|
export const AIEditingIframeButton: React.FC<Omit<AIEditingIframeProps, 'buttonMode'> & {
|
||||||
|
size?: 'sm' | 'md' | 'lg'
|
||||||
|
}> = (props) => {
|
||||||
|
return <AIEditingIframe {...props} buttonMode={true} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AIEditingIframe;
|
||||||
@ -128,6 +128,7 @@ export function useWorkflowData() {
|
|||||||
window.open(`https://smartcut.movieflow.ai/ai-editor/${episodeId}?token=${token}&user_id=${useid}`, '_target');
|
window.open(`https://smartcut.movieflow.ai/ai-editor/${episodeId}?token=${token}&user_id=${useid}`, '_target');
|
||||||
}, [episodeId]);
|
}, [episodeId]);
|
||||||
|
|
||||||
|
// 注释掉自动跳转逻辑,改为手动点击触发
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!from && canGoToCut && taskObject.status !== 'COMPLETED') {
|
if (!from && canGoToCut && taskObject.status !== 'COMPLETED') {
|
||||||
generateEditPlan(true);
|
generateEditPlan(true);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user