- 新增模特详情页组件,支持照片上传和视频生成 - 实现视频生成数据模型和仓库层 - 集成Dify API进行视频生成 - 添加云存储上传功能,自动转换S3 URL为CDN地址 - 实现统一的删除确认弹框,替换window.confirm - 支持照片和视频生成任务的删除功能 - 优化UI/UX设计,符合前端开发规范 - 添加完整的错误处理和状态管理 核心功能: 模特照片上传到云端 多选照片进行视频生成 实时任务状态跟踪 视频生成历史记录 统一删除确认对话框 响应式设计和优雅动画
284 lines
9.3 KiB
Rust
284 lines
9.3 KiB
Rust
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());
|
||
}
|
||
}
|