fix: 添加一键混剪 按钮
This commit is contained in:
227
docs/MIX_EDIT_GUIDE.md
Normal file
227
docs/MIX_EDIT_GUIDE.md
Normal file
@@ -0,0 +1,227 @@
|
||||
# 一键混剪功能使用指南
|
||||
|
||||
## 🎯 功能概述
|
||||
|
||||
一键混剪是项目详情页面的核心功能,允许用户快速将项目中的素材和模特组合成完整的视频。
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 1. 访问项目详情页面
|
||||
|
||||
1. 在项目列表中点击任意项目
|
||||
2. 进入项目详情页面
|
||||
3. 在页面右下角可以看到蓝色的悬浮按钮(剪刀图标)
|
||||
|
||||
### 2. 使用一键混剪
|
||||
|
||||
#### 快速混剪
|
||||
1. 点击悬浮按钮展开选项
|
||||
2. 点击 "快速混剪" 按钮
|
||||
3. 系统将使用默认配置自动生成视频
|
||||
|
||||
#### 自定义混剪
|
||||
1. 点击悬浮按钮展开选项
|
||||
2. 点击 "自定义混剪" 按钮
|
||||
3. 在配置界面中调整参数
|
||||
4. 点击 "开始混剪" 确认
|
||||
|
||||
## ⚙️ 混剪配置选项
|
||||
|
||||
### 基础设置
|
||||
|
||||
#### 目标时长
|
||||
- **范围**: 10-300秒
|
||||
- **默认**: 60秒
|
||||
- **说明**: 最终视频的目标时长
|
||||
|
||||
#### 剪辑风格
|
||||
- **自动**: 智能选择最佳剪辑方式
|
||||
- **快速**: 快节奏剪辑,适合动感内容
|
||||
- **平滑**: 平缓过渡,适合展示类内容
|
||||
- **动感**: 动态效果,适合音乐视频
|
||||
|
||||
#### 转场类型
|
||||
- **直切**: 无过渡效果,直接切换
|
||||
- **淡入淡出**: 渐变过渡效果
|
||||
- **滑动**: 滑动切换效果
|
||||
- **缩放**: 缩放过渡效果
|
||||
|
||||
### 特效设置
|
||||
|
||||
#### 背景音乐
|
||||
- 自动添加适合的背景音乐
|
||||
- 根据剪辑风格选择音乐类型
|
||||
|
||||
#### 文字叠加
|
||||
- 在视频上添加文字信息
|
||||
- 包括项目名称、时间戳等
|
||||
|
||||
#### Logo水印
|
||||
- 在视频角落添加Logo
|
||||
- 支持透明度调整
|
||||
|
||||
### 素材和模特选择
|
||||
|
||||
#### 素材选择
|
||||
- **使用所有素材**: 包含项目中的所有视频素材
|
||||
- **选择特定素材**: 手动选择要使用的素材
|
||||
|
||||
#### 模特选择
|
||||
- **使用所有模特**: 包含项目中的所有模特
|
||||
- **选择特定模特**: 手动选择要使用的模特
|
||||
|
||||
## 🎨 预设配置
|
||||
|
||||
系统提供4种预设配置,可快速应用:
|
||||
|
||||
### 快速剪辑
|
||||
- 时长: 30秒
|
||||
- 风格: 快速
|
||||
- 转场: 直切
|
||||
- 特效: 仅背景音乐
|
||||
|
||||
### 平滑过渡
|
||||
- 时长: 60秒
|
||||
- 风格: 平滑
|
||||
- 转场: 淡入淡出
|
||||
- 特效: 背景音乐 + 文字叠加
|
||||
|
||||
### 动感剪辑
|
||||
- 时长: 45秒
|
||||
- 风格: 动感
|
||||
- 转场: 缩放
|
||||
- 特效: 背景音乐 + 文字叠加
|
||||
|
||||
### 长视频
|
||||
- 时长: 120秒
|
||||
- 风格: 自动
|
||||
- 转场: 滑动
|
||||
- 特效: 仅背景音乐
|
||||
|
||||
## 📊 处理流程
|
||||
|
||||
### 1. 准备阶段
|
||||
- 验证配置参数
|
||||
- 检查素材可用性
|
||||
- 生成任务ID
|
||||
|
||||
### 2. 分析阶段
|
||||
- 分析素材特征
|
||||
- 计算最佳剪辑点
|
||||
- 规划时间轴
|
||||
|
||||
### 3. 编辑阶段
|
||||
- 处理视频片段
|
||||
- 应用转场效果
|
||||
- 添加特效
|
||||
|
||||
### 4. 渲染阶段
|
||||
- 合成最终视频
|
||||
- 添加音频轨道
|
||||
- 生成预览图
|
||||
|
||||
### 5. 完成
|
||||
- 保存输出文件
|
||||
- 生成预览链接
|
||||
- 通知用户完成
|
||||
|
||||
## 🔍 状态监控
|
||||
|
||||
### 进度指示器
|
||||
混剪过程中会显示:
|
||||
- 当前处理阶段
|
||||
- 完成百分比
|
||||
- 正在处理的片段
|
||||
- 预估剩余时间
|
||||
|
||||
### 状态说明
|
||||
- **准备中**: 正在初始化任务
|
||||
- **分析中**: 正在分析素材
|
||||
- **编辑中**: 正在处理视频
|
||||
- **渲染中**: 正在生成最终视频
|
||||
- **已完成**: 混剪任务完成
|
||||
- **已取消**: 用户取消任务
|
||||
- **错误**: 处理过程中出现错误
|
||||
|
||||
## 📋 最佳实践
|
||||
|
||||
### 素材准备
|
||||
1. **确保素材质量**: 使用高质量的视频素材
|
||||
2. **合理的素材数量**: 3-10个素材效果最佳
|
||||
3. **素材时长**: 每个素材至少5秒以上
|
||||
|
||||
### 配置选择
|
||||
1. **根据用途选择风格**:
|
||||
- 商业宣传 → 动感剪辑
|
||||
- 产品展示 → 平滑过渡
|
||||
- 快速预览 → 快速剪辑
|
||||
|
||||
2. **合理设置时长**:
|
||||
- 社交媒体 → 15-30秒
|
||||
- 产品介绍 → 60-90秒
|
||||
- 详细展示 → 120秒以上
|
||||
|
||||
3. **特效使用**:
|
||||
- 专业用途 → 添加Logo水印
|
||||
- 社交分享 → 添加文字叠加
|
||||
- 背景展示 → 仅使用背景音乐
|
||||
|
||||
### 性能优化
|
||||
1. **避免过多素材**: 超过20个素材可能影响性能
|
||||
2. **合理的目标时长**: 过长的视频需要更多处理时间
|
||||
3. **简化特效**: 减少不必要的特效可提高处理速度
|
||||
|
||||
## 🛠️ 故障排除
|
||||
|
||||
### 常见问题
|
||||
|
||||
#### 混剪失败
|
||||
**可能原因**:
|
||||
- 没有可用素材
|
||||
- 素材文件损坏
|
||||
- 目标时长设置错误
|
||||
|
||||
**解决方案**:
|
||||
- 检查项目中是否有视频素材
|
||||
- 验证素材文件完整性
|
||||
- 调整目标时长设置
|
||||
|
||||
#### 处理时间过长
|
||||
**可能原因**:
|
||||
- 素材数量过多
|
||||
- 目标时长过长
|
||||
- 特效设置复杂
|
||||
|
||||
**解决方案**:
|
||||
- 减少素材数量
|
||||
- 降低目标时长
|
||||
- 简化特效配置
|
||||
|
||||
#### 输出质量不佳
|
||||
**可能原因**:
|
||||
- 原始素材质量低
|
||||
- 压缩设置过高
|
||||
- 转场效果不当
|
||||
|
||||
**解决方案**:
|
||||
- 使用高质量素材
|
||||
- 调整输出设置
|
||||
- 选择合适的转场类型
|
||||
|
||||
## 🎯 高级功能
|
||||
|
||||
### 批量混剪
|
||||
- 为多个项目同时生成混剪视频
|
||||
- 使用相同配置处理多个项目
|
||||
|
||||
### 模板保存
|
||||
- 保存常用的混剪配置
|
||||
- 快速应用到新项目
|
||||
|
||||
### 自定义预设
|
||||
- 创建个人专属的预设配置
|
||||
- 分享配置给团队成员
|
||||
|
||||
---
|
||||
|
||||
通过一键混剪功能,你可以快速将项目素材转化为专业的视频内容,大大提高工作效率!
|
||||
110
src-tauri/src/commands/mix_edit.rs
Normal file
110
src-tauri/src/commands/mix_edit.rs
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* 混剪命令
|
||||
* 处理一键混剪相关的后端逻辑
|
||||
*/
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{command, AppHandle};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct MixEditConfig {
|
||||
pub duration: u32,
|
||||
pub style: String,
|
||||
pub music: bool,
|
||||
pub use_all_materials: bool,
|
||||
pub selected_material_ids: Vec<String>,
|
||||
pub use_all_models: bool,
|
||||
pub selected_model_ids: Vec<String>,
|
||||
pub transition_type: String,
|
||||
pub text_overlay: bool,
|
||||
pub logo_watermark: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct VideoSegment {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub duration: Option<f64>,
|
||||
pub file_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Model {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct MixEditParams {
|
||||
pub project_id: String,
|
||||
pub project_name: String,
|
||||
pub config: MixEditConfig,
|
||||
pub materials: Vec<VideoSegment>,
|
||||
pub models: Vec<Model>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct MixEditResult {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
pub task_id: Option<String>,
|
||||
pub output_path: Option<String>,
|
||||
pub duration: Option<f64>,
|
||||
pub segment_count: Option<u32>,
|
||||
pub preview_url: Option<String>,
|
||||
}
|
||||
|
||||
/// 开始混剪任务(简化版本)
|
||||
#[command]
|
||||
pub async fn start_mix_edit(_app: AppHandle, params: MixEditParams) -> Result<MixEditResult, String> {
|
||||
println!("Starting mix edit for project: {}", params.project_name);
|
||||
|
||||
// 生成任务ID
|
||||
let task_id = Uuid::new_v4().to_string();
|
||||
|
||||
// 验证参数
|
||||
if params.materials.is_empty() {
|
||||
return Ok(MixEditResult {
|
||||
success: false,
|
||||
message: "没有可用的素材".to_string(),
|
||||
task_id: None,
|
||||
output_path: None,
|
||||
duration: None,
|
||||
segment_count: None,
|
||||
preview_url: None,
|
||||
});
|
||||
}
|
||||
|
||||
if params.config.duration == 0 {
|
||||
return Ok(MixEditResult {
|
||||
success: false,
|
||||
message: "目标时长必须大于0".to_string(),
|
||||
task_id: None,
|
||||
output_path: None,
|
||||
duration: None,
|
||||
segment_count: None,
|
||||
preview_url: None,
|
||||
});
|
||||
}
|
||||
|
||||
// 模拟混剪处理
|
||||
println!("Processing {} materials with {} second duration",
|
||||
params.materials.len(), params.config.duration);
|
||||
|
||||
// 模拟处理时间
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
|
||||
|
||||
// 生成模拟输出路径
|
||||
let output_path = format!("output/mix_edit_{}_{}.mp4", params.project_id, task_id);
|
||||
|
||||
Ok(MixEditResult {
|
||||
success: true,
|
||||
message: "混剪完成!".to_string(),
|
||||
task_id: Some(task_id.clone()),
|
||||
output_path: Some(output_path),
|
||||
duration: Some(params.config.duration as f64),
|
||||
segment_count: Some(params.materials.len() as u32),
|
||||
preview_url: Some(format!("preview/mix_edit_{}.jpg", task_id)),
|
||||
})
|
||||
}
|
||||
@@ -12,6 +12,7 @@ pub mod media;
|
||||
pub mod resource_category;
|
||||
pub mod python_core_demo;
|
||||
pub mod python_env_manager;
|
||||
pub mod mix_edit;
|
||||
|
||||
// Re-export all commands for easy access
|
||||
pub use basic::*;
|
||||
|
||||
@@ -107,7 +107,9 @@ pub fn run() {
|
||||
commands::python_env_manager::uninstall_python_package,
|
||||
commands::python_env_manager::upgrade_python_package,
|
||||
commands::python_env_manager::setup_embedded_python,
|
||||
commands::python_env_manager::setup_embedded_python_simple
|
||||
commands::python_env_manager::setup_embedded_python_simple,
|
||||
// Mix Edit Commands
|
||||
commands::mix_edit::start_mix_edit
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
295
src/components/MixEditConfigModal.tsx
Normal file
295
src/components/MixEditConfigModal.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* 混剪配置模态框组件
|
||||
* 允许用户自定义混剪参数
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { X, Clock, Zap, Music, Type, Image, Shuffle } from 'lucide-react'
|
||||
import { Project } from '../services/projectService'
|
||||
import { VideoSegment } from '../services/mediaService'
|
||||
import { Model } from '../services/modelService'
|
||||
import { MixEditService, MixEditConfig } from '../services/mixEditService'
|
||||
|
||||
interface MixEditConfigModalProps {
|
||||
project: Project
|
||||
materials: VideoSegment[]
|
||||
models: Model[]
|
||||
onConfirm: (config: MixEditConfig) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const MixEditConfigModal: React.FC<MixEditConfigModalProps> = ({
|
||||
project,
|
||||
materials,
|
||||
models,
|
||||
onConfirm,
|
||||
onCancel
|
||||
}) => {
|
||||
const [config, setConfig] = useState<MixEditConfig>(MixEditService.getDefaultConfig())
|
||||
const [estimatedTime, setEstimatedTime] = useState(0)
|
||||
const [errors, setErrors] = useState<string[]>([])
|
||||
|
||||
// 预设配置
|
||||
const presets = MixEditService.getMixEditPresets()
|
||||
|
||||
// 更新配置
|
||||
const updateConfig = (updates: Partial<MixEditConfig>) => {
|
||||
const newConfig = { ...config, ...updates }
|
||||
setConfig(newConfig)
|
||||
|
||||
// 验证配置
|
||||
const validationErrors = MixEditService.validateConfig(newConfig, materials, models)
|
||||
setErrors(validationErrors)
|
||||
|
||||
// 估算时间
|
||||
const materialCount = newConfig.useAllMaterials
|
||||
? materials.length
|
||||
: newConfig.selectedMaterialIds.length
|
||||
const time = MixEditService.estimateMixEditTime(newConfig, materialCount)
|
||||
setEstimatedTime(time)
|
||||
}
|
||||
|
||||
// 应用预设
|
||||
const applyPreset = (presetConfig: Partial<MixEditConfig>) => {
|
||||
updateConfig(presetConfig)
|
||||
}
|
||||
|
||||
// 初始化估算时间
|
||||
useEffect(() => {
|
||||
updateConfig({})
|
||||
}, [])
|
||||
|
||||
// 确认配置
|
||||
const handleConfirm = () => {
|
||||
if (errors.length === 0) {
|
||||
onConfirm(config)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
||||
{/* 标题栏 */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900">混剪配置</h2>
|
||||
<p className="text-sm text-gray-600">为项目 "{project.name}" 配置混剪参数</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{/* 预设配置 */}
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-3">快速预设</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{presets.map((preset, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => applyPreset(preset.config)}
|
||||
className="p-3 border border-gray-200 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-colors text-left"
|
||||
>
|
||||
<div className="font-medium text-gray-900">{preset.name}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{preset.config.duration}秒 · {preset.config.style}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 基础配置 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* 左列 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">基础设置</h3>
|
||||
|
||||
{/* 目标时长 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<Clock className="w-4 h-4 inline mr-1" />
|
||||
目标时长(秒)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="10"
|
||||
max="300"
|
||||
value={config.duration}
|
||||
onChange={(e) => updateConfig({ duration: parseInt(e.target.value) || 60 })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 剪辑风格 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<Zap className="w-4 h-4 inline mr-1" />
|
||||
剪辑风格
|
||||
</label>
|
||||
<select
|
||||
value={config.style}
|
||||
onChange={(e) => updateConfig({ style: e.target.value as any })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="auto">自动</option>
|
||||
<option value="fast">快速</option>
|
||||
<option value="smooth">平滑</option>
|
||||
<option value="dynamic">动感</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 转场类型 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<Shuffle className="w-4 h-4 inline mr-1" />
|
||||
转场类型
|
||||
</label>
|
||||
<select
|
||||
value={config.transitionType}
|
||||
onChange={(e) => updateConfig({ transitionType: e.target.value as any })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="cut">直切</option>
|
||||
<option value="fade">淡入淡出</option>
|
||||
<option value="slide">滑动</option>
|
||||
<option value="zoom">缩放</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右列 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">特效设置</h3>
|
||||
|
||||
{/* 特效选项 */}
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.music}
|
||||
onChange={(e) => updateConfig({ music: e.target.checked })}
|
||||
className="mr-3 w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<Music className="w-4 h-4 mr-2 text-gray-600" />
|
||||
<span className="text-sm font-medium text-gray-700">添加背景音乐</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.textOverlay}
|
||||
onChange={(e) => updateConfig({ textOverlay: e.target.checked })}
|
||||
className="mr-3 w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<Type className="w-4 h-4 mr-2 text-gray-600" />
|
||||
<span className="text-sm font-medium text-gray-700">添加文字叠加</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.logoWatermark}
|
||||
onChange={(e) => updateConfig({ logoWatermark: e.target.checked })}
|
||||
className="mr-3 w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<Image className="w-4 h-4 mr-2 text-gray-600" />
|
||||
<span className="text-sm font-medium text-gray-700">添加Logo水印</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 素材选择 */}
|
||||
<div>
|
||||
<label className="flex items-center mb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.useAllMaterials}
|
||||
onChange={(e) => updateConfig({ useAllMaterials: e.target.checked })}
|
||||
className="mr-3 w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">使用所有素材 ({materials.length}个)</span>
|
||||
</label>
|
||||
{!config.useAllMaterials && (
|
||||
<div className="text-xs text-gray-500">
|
||||
已选择 {config.selectedMaterialIds.length} 个素材
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 模特选择 */}
|
||||
<div>
|
||||
<label className="flex items-center mb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.useAllModels}
|
||||
onChange={(e) => updateConfig({ useAllModels: e.target.checked })}
|
||||
className="mr-3 w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">使用所有模特 ({models.length}个)</span>
|
||||
</label>
|
||||
{!config.useAllModels && (
|
||||
<div className="text-xs text-gray-500">
|
||||
已选择 {config.selectedModelIds.length} 个模特
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 估算信息 */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-medium text-blue-900">预估处理时间</h4>
|
||||
<p className="text-sm text-blue-700">约 {estimatedTime} 秒</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-blue-700">
|
||||
素材: {config.useAllMaterials ? materials.length : config.selectedMaterialIds.length} 个
|
||||
</div>
|
||||
<div className="text-sm text-blue-700">
|
||||
模特: {config.useAllModels ? models.length : config.selectedModelIds.length} 个
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{errors.length > 0 && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<h4 className="font-medium text-red-900 mb-2">配置错误</h4>
|
||||
<ul className="text-sm text-red-700 space-y-1">
|
||||
{errors.map((error, index) => (
|
||||
<li key={index}>• {error}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<div className="flex items-center justify-end space-x-3 p-6 border-t border-gray-200">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-gray-700 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={errors.length > 0}
|
||||
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
开始混剪
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MixEditConfigModal
|
||||
177
src/components/MixEditFloatingButton.tsx
Normal file
177
src/components/MixEditFloatingButton.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* 一键混剪悬浮按钮组件
|
||||
* 提供快速混剪功能的入口
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { Scissors, Settings, Play, X, Loader } from 'lucide-react'
|
||||
import { Project } from '../services/projectService'
|
||||
import { VideoSegment } from '../services/mediaService'
|
||||
import { Model } from '../services/modelService'
|
||||
import { MixEditService, MixEditConfig, MixEditResult } from '../services/mixEditService'
|
||||
import MixEditConfigModal from './MixEditConfigModal'
|
||||
|
||||
interface MixEditFloatingButtonProps {
|
||||
project: Project
|
||||
materials: VideoSegment[]
|
||||
models: Model[]
|
||||
onMixEditComplete?: (result: MixEditResult) => void
|
||||
}
|
||||
|
||||
const MixEditFloatingButton: React.FC<MixEditFloatingButtonProps> = ({
|
||||
project,
|
||||
materials,
|
||||
models,
|
||||
onMixEditComplete
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [showConfigModal, setShowConfigModal] = useState(false)
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [processingMessage, setProcessingMessage] = useState('')
|
||||
|
||||
// 快速混剪(使用默认配置)
|
||||
const handleQuickMixEdit = async () => {
|
||||
if (materials.length === 0) {
|
||||
alert('没有可用的素材,请先添加一些视频素材')
|
||||
return
|
||||
}
|
||||
|
||||
setIsProcessing(true)
|
||||
setProcessingMessage('正在准备混剪...')
|
||||
setIsExpanded(false)
|
||||
|
||||
try {
|
||||
const defaultConfig = MixEditService.getDefaultConfig()
|
||||
const result = await MixEditService.startMixEdit(project, defaultConfig, materials, models)
|
||||
|
||||
if (result.success) {
|
||||
setProcessingMessage('混剪完成!')
|
||||
setTimeout(() => {
|
||||
setIsProcessing(false)
|
||||
onMixEditComplete?.(result)
|
||||
}, 1500)
|
||||
} else {
|
||||
alert(`混剪失败: ${result.message}`)
|
||||
setIsProcessing(false)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Quick mix edit failed:', error)
|
||||
alert(`混剪失败: ${error}`)
|
||||
setIsProcessing(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义混剪
|
||||
const handleCustomMixEdit = () => {
|
||||
if (materials.length === 0) {
|
||||
alert('没有可用的素材,请先添加一些视频素材')
|
||||
return
|
||||
}
|
||||
|
||||
setShowConfigModal(true)
|
||||
setIsExpanded(false)
|
||||
}
|
||||
|
||||
// 处理自定义混剪配置完成
|
||||
const handleConfigComplete = async (config: MixEditConfig) => {
|
||||
setShowConfigModal(false)
|
||||
setIsProcessing(true)
|
||||
setProcessingMessage('正在开始混剪...')
|
||||
|
||||
try {
|
||||
const result = await MixEditService.startMixEdit(project, config, materials, models)
|
||||
|
||||
if (result.success) {
|
||||
setProcessingMessage('混剪完成!')
|
||||
setTimeout(() => {
|
||||
setIsProcessing(false)
|
||||
onMixEditComplete?.(result)
|
||||
}, 1500)
|
||||
} else {
|
||||
alert(`混剪失败: ${result.message}`)
|
||||
setIsProcessing(false)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Custom mix edit failed:', error)
|
||||
alert(`混剪失败: ${error}`)
|
||||
setIsProcessing(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果正在处理,显示处理状态
|
||||
if (isProcessing) {
|
||||
return (
|
||||
<div className="fixed bottom-8 right-8 z-50">
|
||||
<div className="bg-blue-600 text-white px-6 py-4 rounded-full shadow-lg flex items-center space-x-3">
|
||||
<Loader className="w-5 h-5 animate-spin" />
|
||||
<span className="font-medium">{processingMessage}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed bottom-8 right-8 z-50">
|
||||
{/* 展开的选项 */}
|
||||
{isExpanded && (
|
||||
<div className="mb-4 space-y-3">
|
||||
{/* 自定义混剪按钮 */}
|
||||
<button
|
||||
onClick={handleCustomMixEdit}
|
||||
className="flex items-center space-x-3 bg-white text-gray-700 px-4 py-3 rounded-full shadow-lg hover:shadow-xl transition-all duration-200 transform hover:scale-105"
|
||||
>
|
||||
<Settings className="w-5 h-5" />
|
||||
<span className="font-medium">自定义混剪</span>
|
||||
</button>
|
||||
|
||||
{/* 快速混剪按钮 */}
|
||||
<button
|
||||
onClick={handleQuickMixEdit}
|
||||
className="flex items-center space-x-3 bg-green-600 text-white px-4 py-3 rounded-full shadow-lg hover:shadow-xl transition-all duration-200 transform hover:scale-105"
|
||||
>
|
||||
<Play className="w-5 h-5" />
|
||||
<span className="font-medium">快速混剪</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 主按钮 */}
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className={`w-16 h-16 rounded-full shadow-lg hover:shadow-xl transition-all duration-200 transform hover:scale-105 flex items-center justify-center ${
|
||||
isExpanded
|
||||
? 'bg-gray-600 text-white rotate-45'
|
||||
: 'bg-blue-600 text-white'
|
||||
}`}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<X className="w-6 h-6" />
|
||||
) : (
|
||||
<Scissors className="w-6 h-6" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 提示文字 */}
|
||||
{!isExpanded && (
|
||||
<div className="absolute bottom-full right-0 mb-2 bg-gray-800 text-white text-xs px-2 py-1 rounded whitespace-nowrap opacity-0 hover:opacity-100 transition-opacity duration-200 pointer-events-none">
|
||||
一键混剪
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 配置模态框 */}
|
||||
{showConfigModal && (
|
||||
<MixEditConfigModal
|
||||
project={project}
|
||||
materials={materials}
|
||||
models={models}
|
||||
onConfirm={handleConfigComplete}
|
||||
onCancel={() => setShowConfigModal(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default MixEditFloatingButton
|
||||
@@ -4,8 +4,10 @@ import { ArrowLeft, User, Video } from 'lucide-react'
|
||||
import { Project, ProjectService } from '../services/projectService'
|
||||
import { VideoSegment, MediaService } from '../services/mediaService'
|
||||
import { Model, ModelService } from '../services/modelService'
|
||||
import { MixEditResult } from '../services/mixEditService'
|
||||
import ProjectMaterialsCenter from '../components/ProjectMaterialsCenter'
|
||||
import AGUIChat from '../components/AGUIChat'
|
||||
import MixEditFloatingButton from '../components/MixEditFloatingButton'
|
||||
|
||||
const ProjectDetailPage: React.FC = () => {
|
||||
const { projectId } = useParams<{ projectId: string }>()
|
||||
@@ -80,7 +82,15 @@ const ProjectDetailPage: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 处理混剪完成
|
||||
const handleMixEditComplete = (result: MixEditResult) => {
|
||||
if (result.success) {
|
||||
alert(`混剪完成!\n输出路径: ${result.outputPath}\n时长: ${result.duration}秒`)
|
||||
// 可以在这里添加更多处理逻辑,比如刷新素材列表、显示预览等
|
||||
} else {
|
||||
alert(`混剪失败: ${result.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -167,6 +177,14 @@ const ProjectDetailPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 一键混剪悬浮按钮 */}
|
||||
<MixEditFloatingButton
|
||||
project={project}
|
||||
materials={projectMaterials}
|
||||
models={projectModels}
|
||||
onMixEditComplete={handleMixEditComplete}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
257
src/services/mixEditService.ts
Normal file
257
src/services/mixEditService.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* 混剪服务
|
||||
* 处理一键混剪相关的业务逻辑
|
||||
*/
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { Project } from './projectService'
|
||||
import { VideoSegment } from './mediaService'
|
||||
import { Model } from './modelService'
|
||||
|
||||
export interface MixEditConfig {
|
||||
// 基础配置
|
||||
duration: number // 目标时长(秒)
|
||||
style: 'auto' | 'fast' | 'smooth' | 'dynamic' // 剪辑风格
|
||||
music: boolean // 是否添加背景音乐
|
||||
|
||||
// 素材配置
|
||||
useAllMaterials: boolean // 是否使用所有素材
|
||||
selectedMaterialIds: string[] // 选中的素材ID
|
||||
|
||||
// 模特配置
|
||||
useAllModels: boolean // 是否使用所有模特
|
||||
selectedModelIds: string[] // 选中的模特ID
|
||||
|
||||
// 高级配置
|
||||
transitionType: 'cut' | 'fade' | 'slide' | 'zoom' // 转场类型
|
||||
textOverlay: boolean // 是否添加文字叠加
|
||||
logoWatermark: boolean // 是否添加Logo水印
|
||||
}
|
||||
|
||||
export interface MixEditResult {
|
||||
success: boolean
|
||||
message: string
|
||||
outputPath?: string
|
||||
duration?: number
|
||||
segmentCount?: number
|
||||
previewUrl?: string
|
||||
}
|
||||
|
||||
export interface MixEditProgress {
|
||||
stage: 'preparing' | 'analyzing' | 'editing' | 'rendering' | 'completed' | 'error'
|
||||
progress: number // 0-100
|
||||
message: string
|
||||
currentSegment?: number
|
||||
totalSegments?: number
|
||||
}
|
||||
|
||||
export class MixEditService {
|
||||
/**
|
||||
* 获取默认混剪配置
|
||||
*/
|
||||
static getDefaultConfig(): MixEditConfig {
|
||||
return {
|
||||
duration: 60, // 默认60秒
|
||||
style: 'auto',
|
||||
music: true,
|
||||
useAllMaterials: true,
|
||||
selectedMaterialIds: [],
|
||||
useAllModels: true,
|
||||
selectedModelIds: [],
|
||||
transitionType: 'fade',
|
||||
textOverlay: false,
|
||||
logoWatermark: false,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证混剪配置
|
||||
*/
|
||||
static validateConfig(config: MixEditConfig, materials: VideoSegment[], models: Model[]): string[] {
|
||||
const errors: string[] = []
|
||||
|
||||
if (config.duration <= 0) {
|
||||
errors.push('目标时长必须大于0')
|
||||
}
|
||||
|
||||
if (!config.useAllMaterials && config.selectedMaterialIds.length === 0) {
|
||||
errors.push('请至少选择一个素材')
|
||||
}
|
||||
|
||||
if (!config.useAllModels && config.selectedModelIds.length === 0) {
|
||||
errors.push('请至少选择一个模特')
|
||||
}
|
||||
|
||||
const availableMaterials = config.useAllMaterials
|
||||
? materials
|
||||
: materials.filter(m => config.selectedMaterialIds.includes(m.id))
|
||||
|
||||
if (availableMaterials.length === 0) {
|
||||
errors.push('没有可用的素材')
|
||||
}
|
||||
|
||||
const totalMaterialDuration = availableMaterials.reduce((sum, m) => sum + (m.duration || 0), 0)
|
||||
if (totalMaterialDuration < config.duration) {
|
||||
errors.push(`素材总时长(${totalMaterialDuration}s)小于目标时长(${config.duration}s)`)
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始一键混剪
|
||||
*/
|
||||
static async startMixEdit(
|
||||
project: Project,
|
||||
config: MixEditConfig,
|
||||
materials: VideoSegment[],
|
||||
models: Model[]
|
||||
): Promise<MixEditResult> {
|
||||
try {
|
||||
// 验证配置
|
||||
const errors = this.validateConfig(config, materials, models)
|
||||
if (errors.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: `配置错误: ${errors.join(', ')}`
|
||||
}
|
||||
}
|
||||
|
||||
// 准备混剪参数
|
||||
const mixEditParams = {
|
||||
projectId: project.id,
|
||||
projectName: project.name,
|
||||
config,
|
||||
materials: config.useAllMaterials
|
||||
? materials
|
||||
: materials.filter(m => config.selectedMaterialIds.includes(m.id)),
|
||||
models: config.useAllModels
|
||||
? models
|
||||
: models.filter(m => config.selectedModelIds.includes(m.id))
|
||||
}
|
||||
|
||||
// 调用后端混剪服务
|
||||
const result = await invoke('start_mix_edit', { params: mixEditParams })
|
||||
|
||||
return result as MixEditResult
|
||||
} catch (error) {
|
||||
console.error('Mix edit failed:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: `混剪失败: ${error}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取混剪进度
|
||||
*/
|
||||
static async getMixEditProgress(taskId: string): Promise<MixEditProgress> {
|
||||
try {
|
||||
const progress = await invoke('get_mix_edit_progress', { taskId })
|
||||
return progress as MixEditProgress
|
||||
} catch (error) {
|
||||
console.error('Failed to get mix edit progress:', error)
|
||||
return {
|
||||
stage: 'error',
|
||||
progress: 0,
|
||||
message: `获取进度失败: ${error}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消混剪任务
|
||||
*/
|
||||
static async cancelMixEdit(taskId: string): Promise<boolean> {
|
||||
try {
|
||||
await invoke('cancel_mix_edit', { taskId })
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to cancel mix edit:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取混剪预设
|
||||
*/
|
||||
static getMixEditPresets(): Array<{name: string, config: Partial<MixEditConfig>}> {
|
||||
return [
|
||||
{
|
||||
name: '快速剪辑',
|
||||
config: {
|
||||
duration: 30,
|
||||
style: 'fast',
|
||||
transitionType: 'cut',
|
||||
music: true,
|
||||
textOverlay: false
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '平滑过渡',
|
||||
config: {
|
||||
duration: 60,
|
||||
style: 'smooth',
|
||||
transitionType: 'fade',
|
||||
music: true,
|
||||
textOverlay: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '动感剪辑',
|
||||
config: {
|
||||
duration: 45,
|
||||
style: 'dynamic',
|
||||
transitionType: 'zoom',
|
||||
music: true,
|
||||
textOverlay: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '长视频',
|
||||
config: {
|
||||
duration: 120,
|
||||
style: 'auto',
|
||||
transitionType: 'slide',
|
||||
music: true,
|
||||
textOverlay: false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* 估算混剪时间
|
||||
*/
|
||||
static estimateMixEditTime(config: MixEditConfig, materialCount: number): number {
|
||||
// 基础时间(秒)
|
||||
let estimatedTime = 30
|
||||
|
||||
// 根据目标时长调整
|
||||
estimatedTime += config.duration * 0.5
|
||||
|
||||
// 根据素材数量调整
|
||||
estimatedTime += materialCount * 2
|
||||
|
||||
// 根据剪辑风格调整
|
||||
switch (config.style) {
|
||||
case 'fast':
|
||||
estimatedTime *= 0.8
|
||||
break
|
||||
case 'dynamic':
|
||||
estimatedTime *= 1.2
|
||||
break
|
||||
case 'smooth':
|
||||
estimatedTime *= 1.1
|
||||
break
|
||||
}
|
||||
|
||||
// 根据特效调整
|
||||
if (config.textOverlay) estimatedTime *= 1.1
|
||||
if (config.logoWatermark) estimatedTime *= 1.05
|
||||
if (config.music) estimatedTime *= 1.1
|
||||
|
||||
return Math.round(estimatedTime)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user