forked from 77media/video-flow
360 lines
10 KiB
TypeScript
360 lines
10 KiB
TypeScript
/**
|
||
* AI剪辑按钮组件 - Video-Flow集成OpenCut AI剪辑功能
|
||
* 文件路径: video-flow/components/pages/work-flow/ai-editing-button.tsx
|
||
* 作者: 资深全栈开发工程师
|
||
* 创建时间: 2025-01-08
|
||
*/
|
||
|
||
"use client";
|
||
|
||
import React, { useState, useCallback } from 'react';
|
||
import { motion, AnimatePresence } from 'framer-motion';
|
||
import {
|
||
Zap,
|
||
Loader2,
|
||
CheckCircle,
|
||
AlertCircle,
|
||
Film,
|
||
Sparkles,
|
||
Play,
|
||
Download
|
||
} from 'lucide-react';
|
||
import { GlassIconButton } from '@/components/ui/glass-icon-button';
|
||
import { createAIEditingAdapter, VideoFlowTaskObject, AIEditingAdapter } from './ai-editing-adapter';
|
||
|
||
interface AIEditingButtonProps {
|
||
/** 项目ID */
|
||
projectId: string;
|
||
/** Video-Flow任务对象 */
|
||
taskObject: VideoFlowTaskObject;
|
||
/** 是否禁用按钮 */
|
||
disabled?: boolean;
|
||
/** 按钮大小 */
|
||
size?: 'sm' | 'md' | 'lg';
|
||
/** 完成回调 */
|
||
onComplete?: (finalVideoUrl: string) => void;
|
||
/** 错误回调 */
|
||
onError?: (error: string) => void;
|
||
}
|
||
|
||
interface ProgressState {
|
||
progress: number;
|
||
message: string;
|
||
stage: 'idle' | 'processing' | 'completed' | 'error';
|
||
}
|
||
|
||
/**
|
||
* AI剪辑按钮组件
|
||
* 提供一键AI剪辑功能,集成OpenCut的智能剪辑算法
|
||
*/
|
||
export const AIEditingButton: React.FC<AIEditingButtonProps> = ({
|
||
projectId,
|
||
taskObject,
|
||
disabled = false,
|
||
size = 'md',
|
||
onComplete,
|
||
onError
|
||
}) => {
|
||
const [progressState, setProgressState] = useState<ProgressState>({
|
||
progress: 0,
|
||
message: '',
|
||
stage: 'idle'
|
||
});
|
||
|
||
const [isProcessing, setIsProcessing] = useState(false);
|
||
const [finalVideoUrl, setFinalVideoUrl] = useState<string | null>(null);
|
||
|
||
// 检查是否可以执行AI剪辑
|
||
const canExecute = AIEditingAdapter.canExecuteAIEditing(taskObject);
|
||
const availableVideoCount = AIEditingAdapter.getAvailableVideoCount(taskObject);
|
||
|
||
/**
|
||
* 执行AI剪辑的主要逻辑
|
||
*/
|
||
const handleAIEditing = useCallback(async () => {
|
||
if (!canExecute || isProcessing) {
|
||
return;
|
||
}
|
||
|
||
setIsProcessing(true);
|
||
setProgressState({
|
||
progress: 0,
|
||
message: '准备开始AI剪辑...',
|
||
stage: 'processing'
|
||
});
|
||
|
||
try {
|
||
// 创建AI剪辑适配器
|
||
const adapter = createAIEditingAdapter(projectId, taskObject, {
|
||
onProgress: (progress, message) => {
|
||
setProgressState({
|
||
progress,
|
||
message,
|
||
stage: 'processing'
|
||
});
|
||
},
|
||
onComplete: (url) => {
|
||
setFinalVideoUrl(url);
|
||
setProgressState({
|
||
progress: 100,
|
||
message: 'AI剪辑完成!',
|
||
stage: 'completed'
|
||
});
|
||
onComplete?.(url);
|
||
},
|
||
onError: (error) => {
|
||
setProgressState({
|
||
progress: 0,
|
||
message: error,
|
||
stage: 'error'
|
||
});
|
||
onError?.(error);
|
||
}
|
||
});
|
||
|
||
// 执行自动化AI剪辑
|
||
const resultUrl = await adapter.executeAutoAIEditing();
|
||
console.log('✅ AI剪辑完成,视频URL:', resultUrl);
|
||
|
||
} catch (error) {
|
||
const errorMessage = error instanceof Error ? error.message : 'AI剪辑过程中发生未知错误';
|
||
console.error('❌ AI剪辑失败:', error);
|
||
|
||
setProgressState({
|
||
progress: 0,
|
||
message: errorMessage,
|
||
stage: 'error'
|
||
});
|
||
onError?.(errorMessage);
|
||
} finally {
|
||
setIsProcessing(false);
|
||
|
||
// 3秒后重置状态
|
||
setTimeout(() => {
|
||
if (progressState.stage !== 'processing') {
|
||
setProgressState({
|
||
progress: 0,
|
||
message: '',
|
||
stage: 'idle'
|
||
});
|
||
}
|
||
}, 3000);
|
||
}
|
||
}, [projectId, taskObject, canExecute, isProcessing, onComplete, onError, progressState.stage]);
|
||
|
||
/**
|
||
* 获取按钮显示内容
|
||
*/
|
||
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;
|
||
|
||
return (
|
||
<div className="relative">
|
||
{/* 主按钮 */}
|
||
<motion.button
|
||
onClick={handleAIEditing}
|
||
disabled={disabled || !canExecute || 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: disabled || !canExecute ? 1 : 1.05 }}
|
||
whileTap={{ scale: disabled || !canExecute ? 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' && canExecute && (
|
||
<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>
|
||
|
||
{/* 不可用提示 */}
|
||
{!canExecute && (
|
||
<motion.div
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
className="absolute -top-12 left-1/2 transform -translate-x-1/2 px-3 py-1 rounded-md bg-yellow-500/90 text-white text-xs whitespace-nowrap"
|
||
>
|
||
需要至少1个完成的视频片段
|
||
<div className="absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-yellow-500/90" />
|
||
</motion.div>
|
||
)}
|
||
|
||
{/* 视频信息提示 */}
|
||
{canExecute && (
|
||
<motion.div
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
className="absolute -bottom-8 left-0 right-0 text-center"
|
||
>
|
||
<span className="text-xs text-white/60">
|
||
<Film className="w-3 h-3 inline mr-1" />
|
||
{availableVideoCount}个视频片段可用
|
||
</span>
|
||
</motion.div>
|
||
)}
|
||
|
||
{/* 完成后的下载按钮 */}
|
||
<AnimatePresence>
|
||
{finalVideoUrl && progressState.stage === 'completed' && (
|
||
<motion.a
|
||
href={finalVideoUrl}
|
||
download
|
||
initial={{ opacity: 0, scale: 0.8 }}
|
||
animate={{ opacity: 1, scale: 1 }}
|
||
exit={{ opacity: 0, scale: 0.8 }}
|
||
className="absolute -right-12 top-0 p-2 rounded-full backdrop-blur-lg bg-green-500/20 border border-green-500/30 text-green-400 hover:bg-green-500/30 transition-colors"
|
||
title="下载视频"
|
||
>
|
||
<Download className="w-4 h-4" />
|
||
</motion.a>
|
||
)}
|
||
</AnimatePresence>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
/**
|
||
* 简化版AI剪辑图标按钮
|
||
* 用于空间受限的场景
|
||
*/
|
||
export const AIEditingIconButton: React.FC<Omit<AIEditingButtonProps, 'size'> & {
|
||
size?: 'sm' | 'md' | 'lg'
|
||
}> = ({
|
||
projectId,
|
||
taskObject,
|
||
disabled = false,
|
||
size = 'md',
|
||
onComplete,
|
||
onError
|
||
}) => {
|
||
const [isProcessing, setIsProcessing] = useState(false);
|
||
const canExecute = AIEditingAdapter.canExecuteAIEditing(taskObject);
|
||
|
||
const handleClick = useCallback(async () => {
|
||
if (!canExecute || isProcessing) return;
|
||
|
||
setIsProcessing(true);
|
||
|
||
try {
|
||
const adapter = createAIEditingAdapter(projectId, taskObject, {
|
||
onComplete: (url) => {
|
||
onComplete?.(url);
|
||
setIsProcessing(false);
|
||
},
|
||
onError: (error) => {
|
||
onError?.(error);
|
||
setIsProcessing(false);
|
||
}
|
||
});
|
||
|
||
await adapter.executeAutoAIEditing();
|
||
} catch (error) {
|
||
const errorMessage = error instanceof Error ? error.message : 'AI剪辑失败';
|
||
onError?.(errorMessage);
|
||
setIsProcessing(false);
|
||
}
|
||
}, [projectId, taskObject, canExecute, isProcessing, onComplete, onError]);
|
||
|
||
return (
|
||
<GlassIconButton
|
||
icon={isProcessing ? Loader2 : Zap}
|
||
size={size}
|
||
tooltip={canExecute ? "AI一键剪辑" : "需要完成的视频片段"}
|
||
onClick={handleClick}
|
||
disabled={disabled || !canExecute || isProcessing}
|
||
className={isProcessing ? "animate-pulse" : ""}
|
||
/>
|
||
);
|
||
};
|
||
|
||
export default AIEditingButton;
|
||
|
||
|