Files
mixvideo-v2/apps/desktop/src-tauri/src/infrastructure/video_generation_service.rs
imeepos d5ef9851cd feat: 实现模特详情页视频生成功能
- 新增模特详情页组件,支持照片上传和视频生成
- 实现视频生成数据模型和仓库层
- 集成Dify API进行视频生成
- 添加云存储上传功能,自动转换S3 URL为CDN地址
- 实现统一的删除确认弹框,替换window.confirm
- 支持照片和视频生成任务的删除功能
- 优化UI/UX设计,符合前端开发规范
- 添加完整的错误处理和状态管理

核心功能:
 模特照片上传到云端
 多选照片进行视频生成
 实时任务状态跟踪
 视频生成历史记录
 统一删除确认对话框
 响应式设计和优雅动画
2025-07-16 19:39:44 +08:00

284 lines
9.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use anyhow::{anyhow, Result};
use reqwest::Client;
use std::time::Duration;
use crate::data::models::video_generation::{
DifyApiConfig, DifyApiRequest, DifyApiResponse, DifyInputs, VideoGenerationResult
};
/// 视频生成服务
/// 基于Dify API实现视频生成功能
pub struct VideoGenerationService {
client: Client,
config: DifyApiConfig,
}
impl VideoGenerationService {
/// 创建新的视频生成服务实例
pub fn new() -> Self {
let client = Client::builder()
.timeout(Duration::from_secs(300)) // 5分钟超时
.build()
.expect("Failed to create HTTP client");
Self {
client,
config: DifyApiConfig::default(),
}
}
/// 使用自定义配置创建视频生成服务
pub fn with_config(config: DifyApiConfig) -> Self {
let client = Client::builder()
.timeout(Duration::from_secs(300))
.build()
.expect("Failed to create HTTP client");
Self { client, config }
}
/// 生成视频
pub async fn generate_video(
&self,
product: String,
scene: String,
model_desc: String,
image_url: String,
template: String,
duplicate: u32,
) -> Result<VideoGenerationResult> {
println!("🎬 开始生成视频...");
println!("产品: {}", product);
println!("场景: {}", scene);
println!("模特: {}", model_desc);
println!("图片: {}", image_url);
println!("模板: {}", template);
println!("数量: {}", duplicate);
let start_time = std::time::Instant::now();
// 构建请求数据
let request_data = DifyApiRequest {
inputs: DifyInputs {
product: product.clone(),
scene: scene.clone(),
model: model_desc.clone(),
image: image_url.clone(),
duplicate,
template: template.clone(),
},
response_mode: "blocking".to_string(),
user: "mixvideo-desktop".to_string(),
};
// 发送请求
let url = format!("{}/v1/workflows/run", self.config.host);
let response = self
.client
.post(&url)
.header("Authorization", format!("Bearer {}", self.config.api_key))
.header("Content-Type", "application/json")
.json(&request_data)
.send()
.await
.map_err(|e| anyhow!("请求失败: {}", e))?;
let status = response.status();
let response_text = response
.text()
.await
.map_err(|e| anyhow!("读取响应失败: {}", e))?;
println!("📡 API响应状态: {}", status);
println!("📡 API响应内容: {}", response_text);
if !status.is_success() {
return Err(anyhow!("API请求失败: {} - {}", status, response_text));
}
// 解析响应
let api_response: DifyApiResponse = serde_json::from_str(&response_text)
.map_err(|e| anyhow!("解析响应失败: {} - 响应内容: {}", e, response_text))?;
if let Some(error) = api_response.error {
return Err(anyhow!("API返回错误: {}", error));
}
let data = api_response
.data
.ok_or_else(|| anyhow!("API响应中缺少data字段"))?;
let video_urls = data.outputs.output;
if video_urls.is_empty() {
return Err(anyhow!("API未返回任何视频URL"));
}
let generation_time = start_time.elapsed().as_millis() as u64;
println!("✅ 视频生成成功!");
println!("🎥 生成的视频数量: {}", video_urls.len());
println!("⏱️ 生成耗时: {}ms", generation_time);
// 处理视频路径如果是file://协议,直接使用本地路径)
let video_paths = self.process_video_paths(&video_urls).await?;
Ok(VideoGenerationResult {
video_urls,
video_paths,
generation_time,
api_response: Some(response_text),
})
}
/// 处理视频路径支持file://协议和HTTP URL
async fn process_video_paths(&self, video_urls: &[String]) -> Result<Vec<String>> {
let mut video_paths = Vec::new();
for url in video_urls.iter() {
if url.starts_with("file://") {
// 如果是file://协议,提取本地路径
let local_path = url.replace("file://", "").replace("file:///", "");
println!("📁 使用本地视频文件: {}", local_path);
video_paths.push(local_path);
} else if url.starts_with("http://") || url.starts_with("https://") {
// 如果是HTTP URL尝试下载
match self.download_single_video(url, video_paths.len()).await {
Ok(path) => {
println!("📥 视频下载成功: {}", path);
video_paths.push(path);
}
Err(e) => {
println!("⚠️ 视频下载失败: {} - {}", url, e);
// 下载失败时使用原URL作为路径
video_paths.push(url.clone());
}
}
} else {
// 其他情况直接使用原路径
println!("📄 使用原始路径: {}", url);
video_paths.push(url.clone());
}
}
Ok(video_paths)
}
/// 下载视频到本地(保留原方法以备后用)
async fn download_videos(&self, video_urls: &[String]) -> Result<Vec<String>> {
let mut video_paths = Vec::new();
for (index, url) in video_urls.iter().enumerate() {
match self.download_single_video(url, index).await {
Ok(path) => {
println!("📥 视频下载成功: {}", path);
video_paths.push(path);
}
Err(e) => {
println!("⚠️ 视频下载失败: {} - {}", url, e);
// 下载失败时使用原URL作为路径
video_paths.push(url.clone());
}
}
}
Ok(video_paths)
}
/// 下载单个视频文件
async fn download_single_video(&self, url: &str, index: usize) -> Result<String> {
// 创建下载目录
let download_dir = std::env::current_dir()?.join("downloads").join("videos");
std::fs::create_dir_all(&download_dir)?;
// 生成文件名
let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S").to_string();
let filename = format!("video_{}_{}.mp4", timestamp, index);
let file_path = download_dir.join(&filename);
// 下载文件
let response = self
.client
.get(url)
.send()
.await
.map_err(|e| anyhow!("下载请求失败: {}", e))?;
if !response.status().is_success() {
return Err(anyhow!("下载失败: {}", response.status()));
}
let bytes = response
.bytes()
.await
.map_err(|e| anyhow!("读取视频数据失败: {}", e))?;
std::fs::write(&file_path, bytes)
.map_err(|e| anyhow!("保存视频文件失败: {}", e))?;
Ok(file_path.to_string_lossy().to_string())
}
/// 验证图片URL是否可访问
pub async fn validate_image_url(&self, url: &str) -> Result<bool> {
match self.client.head(url).send().await {
Ok(response) => Ok(response.status().is_success()),
Err(_) => Ok(false),
}
}
/// 获取API配置
pub fn get_config(&self) -> &DifyApiConfig {
&self.config
}
/// 更新API配置
pub fn update_config(&mut self, config: DifyApiConfig) {
self.config = config;
}
/// 测试API连接
pub async fn test_connection(&self) -> Result<bool> {
let url = format!("{}/health", self.config.host);
match self.client.get(&url).send().await {
Ok(response) => Ok(response.status().is_success()),
Err(_) => Ok(false),
}
}
}
impl Default for VideoGenerationService {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_video_generation_service_creation() {
let service = VideoGenerationService::new();
assert_eq!(service.config.host, "https://dify.bowongai.com");
assert_eq!(service.config.api_key, "app-Lm10XUBJKnE1bnG6VPfiD1se");
}
#[tokio::test]
async fn test_custom_config() {
let custom_config = DifyApiConfig {
host: "https://custom.api.com".to_string(),
api_key: "custom-key".to_string(),
};
let service = VideoGenerationService::with_config(custom_config.clone());
assert_eq!(service.config.host, custom_config.host);
assert_eq!(service.config.api_key, custom_config.api_key);
}
#[tokio::test]
async fn test_validate_image_url() {
let service = VideoGenerationService::new();
// 测试一个公开的图片URL
let result = service.validate_image_url("https://httpbin.org/status/200").await;
assert!(result.is_ok());
}
}