feat: 实现模特详情页视频生成功能

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

核心功能:
 模特照片上传到云端
 多选照片进行视频生成
 实时任务状态跟踪
 视频生成历史记录
 统一删除确认对话框
 响应式设计和优雅动画
This commit is contained in:
imeepos
2025-07-16 19:39:44 +08:00
parent 544ee61908
commit d5ef9851cd
22 changed files with 2641 additions and 17 deletions

View File

@@ -0,0 +1,283 @@
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());
}
}