merge: 合并video-generation-feature分支到master
合并内容: - 视频生成功能完整实现 - 火山云API集成和自动下载上传 - 视频预览组件和下载功能 - ComfyUI JSON替换优化 - 换装图片生成功能改进 解决冲突: - App.tsx路由配置合并 - 保留语音生成历史和语音克隆功能 - 新增火山云视频生成工具路由 新增功能: - 视频生成任务管理 - 视频预览和下载 - 自动CDN上传 - 防盗链处理 - 批量操作支持
This commit is contained in:
20
Cargo.lock
generated
20
Cargo.lock
generated
@@ -799,6 +799,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer",
|
"block-buffer",
|
||||||
"crypto-common",
|
"crypto-common",
|
||||||
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1634,6 +1635,15 @@ version = "0.4.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hmac"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "html5ever"
|
name = "html5ever"
|
||||||
version = "0.29.1"
|
version = "0.29.1"
|
||||||
@@ -2348,6 +2358,8 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"dirs 5.0.1",
|
"dirs 5.0.1",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
"hex",
|
||||||
|
"hmac",
|
||||||
"lazy_static",
|
"lazy_static",
|
||||||
"md5",
|
"md5",
|
||||||
"num_cpus",
|
"num_cpus",
|
||||||
@@ -2359,6 +2371,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_yaml",
|
"serde_yaml",
|
||||||
|
"sha2",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
@@ -2375,6 +2388,7 @@ dependencies = [
|
|||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"tree-sitter",
|
"tree-sitter",
|
||||||
"tree-sitter-json",
|
"tree-sitter-json",
|
||||||
|
"url",
|
||||||
"uuid",
|
"uuid",
|
||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
@@ -4108,6 +4122,12 @@ version = "0.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "subtle"
|
||||||
|
version = "2.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "swift-rs"
|
name = "swift-rs"
|
||||||
version = "1.0.7"
|
version = "1.0.7"
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ num_cpus = "1.16"
|
|||||||
tokio-tungstenite = "0.20"
|
tokio-tungstenite = "0.20"
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
|
hmac = "0.12.1"
|
||||||
|
sha2 = "0.10.9"
|
||||||
|
hex = "0.4.3"
|
||||||
|
url = "2.5.4"
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
winapi = { version = "0.3", features = ["sysinfoapi"] }
|
winapi = { version = "0.3", features = ["sysinfoapi"] }
|
||||||
|
|||||||
@@ -330,7 +330,6 @@ impl ComfyUIService {
|
|||||||
"model" => {
|
"model" => {
|
||||||
// 确保使用CDN格式的URL
|
// 确保使用CDN格式的URL
|
||||||
let cdn_url = self.convert_to_cdn_url(model_image_url);
|
let cdn_url = self.convert_to_cdn_url(model_image_url);
|
||||||
let filename = self.extract_filename_from_url(&cdn_url);
|
|
||||||
|
|
||||||
// 替换image_url字段
|
// 替换image_url字段
|
||||||
replacements.push(WorkflowNodeReplacement {
|
replacements.push(WorkflowNodeReplacement {
|
||||||
@@ -339,17 +338,10 @@ impl ComfyUIService {
|
|||||||
value: Value::String(cdn_url),
|
value: Value::String(cdn_url),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 替换image字段为文件名
|
|
||||||
replacements.push(WorkflowNodeReplacement {
|
|
||||||
node_id: node_id.clone(),
|
|
||||||
input_field: "image".to_string(),
|
|
||||||
value: Value::String(filename),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
"product" => {
|
"product" => {
|
||||||
// 确保使用CDN格式的URL
|
// 确保使用CDN格式的URL
|
||||||
let cdn_url = self.convert_to_cdn_url(product_image_url);
|
let cdn_url = self.convert_to_cdn_url(product_image_url);
|
||||||
let filename = self.extract_filename_from_url(&cdn_url);
|
|
||||||
|
|
||||||
// 替换image_url字段
|
// 替换image_url字段
|
||||||
replacements.push(WorkflowNodeReplacement {
|
replacements.push(WorkflowNodeReplacement {
|
||||||
@@ -358,12 +350,6 @@ impl ComfyUIService {
|
|||||||
value: Value::String(cdn_url),
|
value: Value::String(cdn_url),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 替换image字段为文件名
|
|
||||||
replacements.push(WorkflowNodeReplacement {
|
|
||||||
node_id: node_id.clone(),
|
|
||||||
input_field: "image".to_string(),
|
|
||||||
value: Value::String(filename),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
"prompt" => {
|
"prompt" => {
|
||||||
// 只有当 prompt 不为空时才进行替换,保持工作流原始设置
|
// 只有当 prompt 不为空时才进行替换,保持工作流原始设置
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ pub mod comfyui_service;
|
|||||||
pub mod outfit_photo_generation_service;
|
pub mod outfit_photo_generation_service;
|
||||||
pub mod workflow_management_service;
|
pub mod workflow_management_service;
|
||||||
pub mod error_handling_service;
|
pub mod error_handling_service;
|
||||||
|
pub mod volcano_video_service;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub mod tests;
|
pub mod tests;
|
||||||
|
|||||||
@@ -0,0 +1,738 @@
|
|||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use reqwest::Client;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
use uuid::Uuid;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use hmac::{Hmac, Mac};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use hex;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
use crate::data::models::video_generation_record::{
|
||||||
|
VideoGenerationRecord, CreateVideoGenerationRequest, VideoGenerationQuery
|
||||||
|
};
|
||||||
|
use crate::data::repositories::video_generation_record_repository::VideoGenerationRecordRepository;
|
||||||
|
use crate::infrastructure::database::Database;
|
||||||
|
use crate::business::services::cloud_upload_service::CloudUploadService;
|
||||||
|
|
||||||
|
/// 火山云视频生成API响应(提交任务)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct VolcanoVideoGenerationResponse {
|
||||||
|
pub code: i32,
|
||||||
|
pub message: String,
|
||||||
|
pub data: Option<VolcanoVideoGenerationData>,
|
||||||
|
pub request_id: String,
|
||||||
|
pub status: i32,
|
||||||
|
pub time_elapsed: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 提交任务返回数据
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct VolcanoVideoGenerationData {
|
||||||
|
pub task_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 火山云查询任务API响应
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct VolcanoVideoQueryResponse {
|
||||||
|
pub code: i32,
|
||||||
|
pub message: String,
|
||||||
|
pub data: Option<VolcanoVideoQueryData>,
|
||||||
|
pub request_id: String,
|
||||||
|
pub status: i32,
|
||||||
|
pub time_elapsed: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 查询任务返回数据
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct VolcanoVideoQueryData {
|
||||||
|
pub status: String, // in_queue, generating, done, not_found, expired
|
||||||
|
pub video_url: Option<String>,
|
||||||
|
pub resp_data: Option<String>, // JSON字符串,包含详细信息
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 驱动视频信息
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DrivingVideoInfo {
|
||||||
|
/// 驱动视频存储类型,传固定值0
|
||||||
|
pub store_type: i32,
|
||||||
|
/// 视频url
|
||||||
|
pub video_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 火山云视频生成请求参数(提交任务)
|
||||||
|
/// 基于火山云实际API文档的参数
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct VolcanoVideoGenerationRequest {
|
||||||
|
/// 服务标识,固定值
|
||||||
|
pub req_key: String,
|
||||||
|
/// 输入图片URL(必需)
|
||||||
|
pub image_url: String,
|
||||||
|
/// 驱动视频信息
|
||||||
|
pub driving_video_info: DrivingVideoInfo,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 火山云查询任务请求参数
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct VolcanoVideoQueryRequest {
|
||||||
|
/// 服务标识,固定值
|
||||||
|
pub req_key: String,
|
||||||
|
/// 任务ID
|
||||||
|
pub task_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 火山云视频生成服务
|
||||||
|
/// 遵循 Tauri 开发规范的服务层设计原则
|
||||||
|
pub struct VolcanoVideoService {
|
||||||
|
database: Arc<Database>,
|
||||||
|
repository: VideoGenerationRecordRepository,
|
||||||
|
http_client: Client,
|
||||||
|
cloud_upload_service: CloudUploadService,
|
||||||
|
vol_access_key: String,
|
||||||
|
vol_secret_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VolcanoVideoService {
|
||||||
|
/// 创建新的火山云视频生成服务实例
|
||||||
|
pub fn new(database: Arc<Database>) -> Self {
|
||||||
|
let repository = VideoGenerationRecordRepository::new(database.clone());
|
||||||
|
let http_client = Client::new();
|
||||||
|
|
||||||
|
// 从环境变量或配置中获取火山云密钥
|
||||||
|
let vol_access_key = std::env::var("VOL_ACCESS_KEY_ID")
|
||||||
|
.unwrap_or_else(|_| "AKLTZmEwYzNlZDI4NDI2NDUyNDg1ZTQ5NzVlNmQ4OGNkMDY".to_string());
|
||||||
|
let vol_secret_key = std::env::var("VOL_ACCESS_SECRET_KEY")
|
||||||
|
.unwrap_or_else(|_| "T1RRd1l6UTROV05pTUdRMU5EWTRNV0V4TldGak9ESmhZbVF4TnpCalpHTQ==".to_string());
|
||||||
|
|
||||||
|
Self {
|
||||||
|
database,
|
||||||
|
repository,
|
||||||
|
http_client,
|
||||||
|
cloud_upload_service: CloudUploadService::new(),
|
||||||
|
vol_access_key,
|
||||||
|
vol_secret_key,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建视频生成任务
|
||||||
|
pub async fn create_video_generation(&self, request: CreateVideoGenerationRequest) -> Result<VideoGenerationRecord> {
|
||||||
|
info!("创建视频生成任务: {}", request.name);
|
||||||
|
|
||||||
|
// 验证输入参数
|
||||||
|
if request.image_url.is_none() {
|
||||||
|
return Err(anyhow!("图片URL不能为空"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建记录
|
||||||
|
let id = Uuid::new_v4().to_string();
|
||||||
|
let mut record = VideoGenerationRecord::new(
|
||||||
|
id,
|
||||||
|
request.name,
|
||||||
|
request.image_url,
|
||||||
|
request.audio_url,
|
||||||
|
request.prompt,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 设置可选参数
|
||||||
|
if let Some(desc) = request.description {
|
||||||
|
record.description = Some(desc);
|
||||||
|
}
|
||||||
|
if let Some(negative_prompt) = request.negative_prompt {
|
||||||
|
record.negative_prompt = Some(negative_prompt);
|
||||||
|
}
|
||||||
|
if let Some(duration) = request.duration {
|
||||||
|
record.duration = duration;
|
||||||
|
}
|
||||||
|
if let Some(fps) = request.fps {
|
||||||
|
record.fps = fps;
|
||||||
|
}
|
||||||
|
if let Some(resolution) = request.resolution {
|
||||||
|
record.resolution = resolution;
|
||||||
|
}
|
||||||
|
if let Some(style) = request.style {
|
||||||
|
record.style = Some(style);
|
||||||
|
}
|
||||||
|
if let Some(motion_strength) = request.motion_strength {
|
||||||
|
record.motion_strength = motion_strength;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存到数据库
|
||||||
|
self.repository.create(&record).await?;
|
||||||
|
|
||||||
|
// 异步启动视频生成任务
|
||||||
|
let service_clone = self.clone();
|
||||||
|
let record_id = record.id.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = service_clone.process_video_generation(record_id).await {
|
||||||
|
error!("视频生成任务处理失败: {}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 处理视频生成任务
|
||||||
|
async fn process_video_generation(&self, record_id: String) -> Result<()> {
|
||||||
|
info!("开始处理视频生成任务: {}", record_id);
|
||||||
|
|
||||||
|
// 获取记录
|
||||||
|
let mut record = self.repository.get_by_id(&record_id).await?
|
||||||
|
.ok_or_else(|| anyhow!("找不到视频生成记录: {}", record_id))?;
|
||||||
|
|
||||||
|
// 标记为处理中
|
||||||
|
record.mark_as_processing(None);
|
||||||
|
self.repository.update(&record).await?;
|
||||||
|
|
||||||
|
// 调用火山云API
|
||||||
|
match self.call_volcano_api(&record).await {
|
||||||
|
Ok(response) => {
|
||||||
|
if let Some(data) = response.data {
|
||||||
|
// 更新任务ID
|
||||||
|
record.vol_task_id = Some(data.task_id.clone());
|
||||||
|
record.vol_request_id = Some(response.request_id);
|
||||||
|
self.repository.update(&record).await?;
|
||||||
|
|
||||||
|
// 轮询任务状态
|
||||||
|
self.poll_task_status(record_id, data.task_id).await?;
|
||||||
|
} else {
|
||||||
|
record.mark_as_failed("火山云API返回数据为空".to_string(), Some("EMPTY_RESPONSE".to_string()));
|
||||||
|
self.repository.update(&record).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("调用火山云API失败: {}", e);
|
||||||
|
record.mark_as_failed(e.to_string(), Some("API_ERROR".to_string()));
|
||||||
|
self.repository.update(&record).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 调用火山云视频生成API
|
||||||
|
async fn call_volcano_api(&self, record: &VideoGenerationRecord) -> Result<VolcanoVideoGenerationResponse> {
|
||||||
|
// 获取本地文件路径
|
||||||
|
let image_path = record.image_url.as_ref()
|
||||||
|
.ok_or_else(|| anyhow!("图片路径不能为空"))?;
|
||||||
|
let driving_video_path = record.audio_url.as_ref()
|
||||||
|
.ok_or_else(|| anyhow!("驱动视频路径不能为空,请上传驱动视频文件"))?;
|
||||||
|
|
||||||
|
// 上传图片到云端
|
||||||
|
info!("正在上传图片到云端: {}", image_path);
|
||||||
|
let image_upload_result = self.cloud_upload_service
|
||||||
|
.upload_file(image_path, None, None)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !image_upload_result.success {
|
||||||
|
return Err(anyhow!("图片上传失败: {}",
|
||||||
|
image_upload_result.error_message.unwrap_or_default()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let image_s3_url = image_upload_result.remote_url
|
||||||
|
.ok_or_else(|| anyhow!("图片上传成功但未返回URL"))?;
|
||||||
|
|
||||||
|
// 将S3 URL转换为HTTPS CDN URL
|
||||||
|
let image_url = Self::convert_s3_to_cdn_url(&image_s3_url);
|
||||||
|
info!("图片S3 URL: {} -> CDN URL: {}", image_s3_url, image_url);
|
||||||
|
|
||||||
|
// 上传驱动视频到云端
|
||||||
|
info!("正在上传驱动视频到云端: {}", driving_video_path);
|
||||||
|
let video_upload_result = self.cloud_upload_service
|
||||||
|
.upload_file(driving_video_path, None, None)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !video_upload_result.success {
|
||||||
|
return Err(anyhow!("驱动视频上传失败: {}",
|
||||||
|
video_upload_result.error_message.unwrap_or_default()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let driving_video_s3_url = video_upload_result.remote_url
|
||||||
|
.ok_or_else(|| anyhow!("驱动视频上传成功但未返回URL"))?;
|
||||||
|
|
||||||
|
// 将S3 URL转换为HTTPS CDN URL
|
||||||
|
let driving_video_url = Self::convert_s3_to_cdn_url(&driving_video_s3_url);
|
||||||
|
info!("驱动视频S3 URL: {} -> CDN URL: {}", driving_video_s3_url, driving_video_url);
|
||||||
|
|
||||||
|
info!("文件上传完成 - 图片: {}, 驱动视频: {}", image_url, driving_video_url);
|
||||||
|
|
||||||
|
let request_body = VolcanoVideoGenerationRequest {
|
||||||
|
req_key: "realman_avatar_imitator_v2v_gen_video".to_string(),
|
||||||
|
image_url,
|
||||||
|
driving_video_info: DrivingVideoInfo {
|
||||||
|
store_type: 0, // 固定值
|
||||||
|
video_url: driving_video_url,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
info!("调用火山云视频生成API: {:?}", request_body);
|
||||||
|
|
||||||
|
// 实际的火山云API调用 - 提交任务
|
||||||
|
let api_url = "https://visual.volcengineapi.com?Action=CVSubmitTask&Version=2022-08-31";
|
||||||
|
|
||||||
|
// 构建认证头
|
||||||
|
let now = Utc::now();
|
||||||
|
let timestamp = now.format("%Y%m%dT%H%M%SZ").to_string();
|
||||||
|
let date = now.format("%Y%m%d").to_string();
|
||||||
|
let auth_header = self.build_auth_header("POST", api_url, ×tamp, &date, &request_body)?;
|
||||||
|
|
||||||
|
let response = self.http_client
|
||||||
|
.post(api_url)
|
||||||
|
.header("Authorization", auth_header)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("X-Date", timestamp)
|
||||||
|
.json(&request_body)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
let error_text = response.text().await.unwrap_or_default();
|
||||||
|
return Err(anyhow!("火山云API调用失败: {} - {}", status, error_text));
|
||||||
|
}
|
||||||
|
|
||||||
|
let api_response: VolcanoVideoGenerationResponse = response.json().await?;
|
||||||
|
|
||||||
|
if api_response.code != 10000 {
|
||||||
|
return Err(anyhow!("火山云API返回错误: {} - {}", api_response.code, api_response.message));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(api_response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 轮询任务状态
|
||||||
|
async fn poll_task_status(&self, record_id: String, task_id: String) -> Result<()> {
|
||||||
|
info!("开始轮询任务状态: {} - {}", record_id, task_id);
|
||||||
|
|
||||||
|
let mut attempts = 0;
|
||||||
|
let max_attempts = 60; // 最多轮询60次(约10分钟)
|
||||||
|
let mut consecutive_failures = 0;
|
||||||
|
let max_consecutive_failures = 3; // 连续失败3次就标记为失败
|
||||||
|
|
||||||
|
while attempts < max_attempts {
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_secs(10)).await;
|
||||||
|
attempts += 1;
|
||||||
|
|
||||||
|
match self.check_task_status(&task_id).await {
|
||||||
|
Ok(status_response) => {
|
||||||
|
// 重置连续失败计数器
|
||||||
|
consecutive_failures = 0;
|
||||||
|
if let Some(data) = status_response.data {
|
||||||
|
let mut record = self.repository.get_by_id(&record_id).await?
|
||||||
|
.ok_or_else(|| anyhow!("找不到视频生成记录: {}", record_id))?;
|
||||||
|
|
||||||
|
match data.status.as_str() {
|
||||||
|
"done" => {
|
||||||
|
if let Some(video_url) = data.video_url {
|
||||||
|
// 下载视频并上传到云端
|
||||||
|
match self.download_and_upload_video(&video_url, &record_id).await {
|
||||||
|
Ok(cdn_url) => {
|
||||||
|
record.mark_as_completed(cdn_url, None); // 使用CDN URL
|
||||||
|
self.repository.update(&record).await?;
|
||||||
|
info!("视频生成任务完成,已上传到CDN: {}", record_id);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("视频上传到CDN失败,使用原始URL: {} - {}", record_id, e);
|
||||||
|
record.mark_as_completed(video_url, None); // 使用原始URL作为fallback
|
||||||
|
self.repository.update(&record).await?;
|
||||||
|
info!("视频生成任务完成(使用原始URL): {}", record_id);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
record.mark_as_failed("视频URL为空".to_string(), Some("EMPTY_VIDEO_URL".to_string()));
|
||||||
|
self.repository.update(&record).await?;
|
||||||
|
return Err(anyhow!("视频生成完成但视频URL为空"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"not_found" => {
|
||||||
|
record.mark_as_failed("任务未找到".to_string(), Some("TASK_NOT_FOUND".to_string()));
|
||||||
|
self.repository.update(&record).await?;
|
||||||
|
return Err(anyhow!("火山云任务未找到"));
|
||||||
|
}
|
||||||
|
"expired" => {
|
||||||
|
record.mark_as_failed("任务已过期".to_string(), Some("TASK_EXPIRED".to_string()));
|
||||||
|
self.repository.update(&record).await?;
|
||||||
|
return Err(anyhow!("火山云任务已过期"));
|
||||||
|
}
|
||||||
|
"in_queue" | "generating" => {
|
||||||
|
// 任务还在处理中,继续轮询
|
||||||
|
info!("任务状态: {} - {}", data.status, record_id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
warn!("未知的任务状态: {}", data.status);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("检查任务状态失败: {}", e);
|
||||||
|
consecutive_failures += 1;
|
||||||
|
|
||||||
|
// 如果连续失败次数达到上限,标记任务为失败
|
||||||
|
if consecutive_failures >= max_consecutive_failures {
|
||||||
|
error!("连续{}次检查任务状态失败,标记任务为失败: {}", max_consecutive_failures, e);
|
||||||
|
let mut record = self.repository.get_by_id(&record_id).await?
|
||||||
|
.ok_or_else(|| anyhow!("找不到视频生成记录: {}", record_id))?;
|
||||||
|
record.mark_as_failed(
|
||||||
|
format!("连续{}次API调用失败: {}", max_consecutive_failures, e),
|
||||||
|
Some("API_CONSECUTIVE_FAILURES".to_string())
|
||||||
|
);
|
||||||
|
self.repository.update(&record).await?;
|
||||||
|
return Err(anyhow!("连续{}次检查任务状态失败", max_consecutive_failures));
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 超时处理
|
||||||
|
let mut record = self.repository.get_by_id(&record_id).await?
|
||||||
|
.ok_or_else(|| anyhow!("找不到视频生成记录: {}", record_id))?;
|
||||||
|
record.mark_as_failed("任务超时".to_string(), Some("TIMEOUT".to_string()));
|
||||||
|
self.repository.update(&record).await?;
|
||||||
|
|
||||||
|
Err(anyhow!("视频生成任务超时"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查任务状态
|
||||||
|
async fn check_task_status(&self, task_id: &str) -> Result<VolcanoVideoQueryResponse> {
|
||||||
|
info!("检查任务状态: {}", task_id);
|
||||||
|
|
||||||
|
// 构建查询请求
|
||||||
|
let request_body = VolcanoVideoQueryRequest {
|
||||||
|
req_key: "realman_avatar_imitator_v2v_gen_video".to_string(),
|
||||||
|
task_id: task_id.to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 实际的火山云API调用 - 查询任务
|
||||||
|
let api_url = "https://visual.volcengineapi.com?Action=CVGetResult&Version=2022-08-31";
|
||||||
|
|
||||||
|
// 构建认证头
|
||||||
|
let now = Utc::now();
|
||||||
|
let timestamp = now.format("%Y%m%dT%H%M%SZ").to_string();
|
||||||
|
let date = now.format("%Y%m%d").to_string();
|
||||||
|
let auth_header = self.build_auth_header("POST", api_url, ×tamp, &date, &request_body)?;
|
||||||
|
|
||||||
|
let response = self.http_client
|
||||||
|
.post(api_url)
|
||||||
|
.header("Authorization", auth_header)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("X-Date", timestamp)
|
||||||
|
.json(&request_body)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
let error_text = response.text().await.unwrap_or_default();
|
||||||
|
return Err(anyhow!("火山云API调用失败: {} - {}", status, error_text));
|
||||||
|
}
|
||||||
|
|
||||||
|
let api_response: VolcanoVideoQueryResponse = response.json().await?;
|
||||||
|
|
||||||
|
if api_response.code != 10000 {
|
||||||
|
return Err(anyhow!("火山云API返回错误: {} - {}", api_response.code, api_response.message));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(api_response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 构建火山云API认证头
|
||||||
|
fn build_auth_header<T: Serialize>(&self, method: &str, url: &str, timestamp: &str, date: &str, body: &T) -> Result<String> {
|
||||||
|
// 1. 创建规范请求 (CanonicalRequest)
|
||||||
|
let canonical_request = self.create_canonical_request(method, url, timestamp, body)?;
|
||||||
|
|
||||||
|
// 2. 创建待签名字符串 (StringToSign)
|
||||||
|
let credential_scope = format!("{}/cn-north-1/cv/request", date);
|
||||||
|
let string_to_sign = self.create_string_to_sign(timestamp, &credential_scope, &canonical_request)?;
|
||||||
|
|
||||||
|
// 3. 派生签名密钥 (kSigning)
|
||||||
|
let signing_key = self.derive_signing_key(&self.vol_secret_key, date, "cn-north-1", "cv")?;
|
||||||
|
|
||||||
|
// 4. 计算签名 (Signature)
|
||||||
|
let signature = self.calculate_signature(&signing_key, &string_to_sign)?;
|
||||||
|
|
||||||
|
// 5. 构建Authorization头
|
||||||
|
let auth_string = format!(
|
||||||
|
"HMAC-SHA256 Credential={}/{}, SignedHeaders=host;x-date, Signature={}",
|
||||||
|
self.vol_access_key,
|
||||||
|
credential_scope,
|
||||||
|
signature
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(auth_string)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取视频生成记录列表
|
||||||
|
pub async fn get_video_generations(&self, query: VideoGenerationQuery) -> Result<Vec<VideoGenerationRecord>> {
|
||||||
|
self.repository.get_list(query).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 根据ID获取视频生成记录
|
||||||
|
pub async fn get_video_generation_by_id(&self, id: &str) -> Result<Option<VideoGenerationRecord>> {
|
||||||
|
self.repository.get_by_id(id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除视频生成记录
|
||||||
|
pub async fn delete_video_generation(&self, id: &str) -> Result<()> {
|
||||||
|
self.repository.delete(id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 批量删除视频生成记录
|
||||||
|
pub async fn batch_delete_video_generations(&self, ids: Vec<String>) -> Result<()> {
|
||||||
|
for id in ids {
|
||||||
|
self.repository.delete(&id).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建规范请求
|
||||||
|
fn create_canonical_request<T: Serialize>(&self, method: &str, url: &str, timestamp: &str, body: &T) -> Result<String> {
|
||||||
|
// 解析URL获取host和path
|
||||||
|
let parsed_url = Url::parse(url)?;
|
||||||
|
let host = parsed_url.host_str().ok_or_else(|| anyhow!("无效的URL"))?;
|
||||||
|
let path = parsed_url.path();
|
||||||
|
let query = parsed_url.query().unwrap_or("");
|
||||||
|
|
||||||
|
// 规范化查询字符串
|
||||||
|
let canonical_query_string = self.canonicalize_query_string(query);
|
||||||
|
|
||||||
|
// 规范化请求头
|
||||||
|
let canonical_headers = format!("host:{}\nx-date:{}\n", host, timestamp);
|
||||||
|
|
||||||
|
// 签名的请求头
|
||||||
|
let signed_headers = "host;x-date";
|
||||||
|
|
||||||
|
// 计算请求体的哈希值
|
||||||
|
let body_json = serde_json::to_string(body)?;
|
||||||
|
let body_hash = hex::encode(Sha256::digest(body_json.as_bytes()));
|
||||||
|
|
||||||
|
// 构建规范请求
|
||||||
|
let canonical_request = format!(
|
||||||
|
"{}\n{}\n{}\n{}\n{}\n{}",
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
canonical_query_string,
|
||||||
|
canonical_headers,
|
||||||
|
signed_headers,
|
||||||
|
body_hash
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(canonical_request)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 规范化查询字符串
|
||||||
|
fn canonicalize_query_string(&self, query: &str) -> String {
|
||||||
|
if query.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut params: Vec<(String, String)> = query
|
||||||
|
.split('&')
|
||||||
|
.filter_map(|param| {
|
||||||
|
let mut parts = param.splitn(2, '=');
|
||||||
|
let key = parts.next()?.to_string();
|
||||||
|
let value = parts.next().unwrap_or("").to_string();
|
||||||
|
Some((key, value))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// 按参数名称的ASCII升序排序
|
||||||
|
params.sort_by(|a, b| a.0.cmp(&b.0));
|
||||||
|
|
||||||
|
// 构建规范化查询字符串
|
||||||
|
params
|
||||||
|
.into_iter()
|
||||||
|
.map(|(key, value)| format!("{}={}", key, value))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("&")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建待签名字符串
|
||||||
|
fn create_string_to_sign(&self, timestamp: &str, credential_scope: &str, canonical_request: &str) -> Result<String> {
|
||||||
|
let canonical_request_hash = hex::encode(Sha256::digest(canonical_request.as_bytes()));
|
||||||
|
|
||||||
|
let string_to_sign = format!(
|
||||||
|
"HMAC-SHA256\n{}\n{}\n{}",
|
||||||
|
timestamp,
|
||||||
|
credential_scope,
|
||||||
|
canonical_request_hash
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(string_to_sign)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 派生签名密钥
|
||||||
|
fn derive_signing_key(&self, secret_key: &str, date: &str, region: &str, service: &str) -> Result<Vec<u8>> {
|
||||||
|
type HmacSha256 = Hmac<Sha256>;
|
||||||
|
|
||||||
|
// kDate = HMAC(kSecret, Date)
|
||||||
|
let mut mac = HmacSha256::new_from_slice(secret_key.as_bytes())?;
|
||||||
|
mac.update(date.as_bytes());
|
||||||
|
let k_date = mac.finalize().into_bytes();
|
||||||
|
|
||||||
|
// kRegion = HMAC(kDate, Region)
|
||||||
|
let mut mac = HmacSha256::new_from_slice(&k_date)?;
|
||||||
|
mac.update(region.as_bytes());
|
||||||
|
let k_region = mac.finalize().into_bytes();
|
||||||
|
|
||||||
|
// kService = HMAC(kRegion, Service)
|
||||||
|
let mut mac = HmacSha256::new_from_slice(&k_region)?;
|
||||||
|
mac.update(service.as_bytes());
|
||||||
|
let k_service = mac.finalize().into_bytes();
|
||||||
|
|
||||||
|
// kSigning = HMAC(kService, "request")
|
||||||
|
let mut mac = HmacSha256::new_from_slice(&k_service)?;
|
||||||
|
mac.update(b"request");
|
||||||
|
let k_signing = mac.finalize().into_bytes();
|
||||||
|
|
||||||
|
Ok(k_signing.to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 计算签名
|
||||||
|
fn calculate_signature(&self, signing_key: &[u8], string_to_sign: &str) -> Result<String> {
|
||||||
|
type HmacSha256 = Hmac<Sha256>;
|
||||||
|
|
||||||
|
let mut mac = HmacSha256::new_from_slice(signing_key)?;
|
||||||
|
mac.update(string_to_sign.as_bytes());
|
||||||
|
let signature = mac.finalize().into_bytes();
|
||||||
|
|
||||||
|
Ok(hex::encode(signature))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 下载视频并上传到云端服务
|
||||||
|
async fn download_and_upload_video(&self, video_url: &str, record_id: &str) -> Result<String> {
|
||||||
|
info!("开始下载并上传视频: {} -> {}", video_url, record_id);
|
||||||
|
|
||||||
|
// 创建临时文件路径
|
||||||
|
let temp_dir = std::env::temp_dir();
|
||||||
|
let temp_filename = format!("volcano_video_{}_{}.mp4", record_id, chrono::Utc::now().timestamp());
|
||||||
|
let temp_file_path = temp_dir.join(&temp_filename);
|
||||||
|
let temp_file_str = temp_file_path.to_string_lossy().to_string();
|
||||||
|
|
||||||
|
// 下载视频到本地临时文件
|
||||||
|
info!("正在下载视频到临时文件: {}", temp_file_str);
|
||||||
|
match self.download_video_to_file(video_url, &temp_file_str).await {
|
||||||
|
Ok(_) => {
|
||||||
|
info!("视频下载成功: {}", temp_file_str);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("视频下载失败: {} - {}", video_url, e);
|
||||||
|
return Err(anyhow!("视频下载失败: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上传到云端服务
|
||||||
|
info!("正在上传视频到云端: {}", temp_file_str);
|
||||||
|
let upload_result = match self.cloud_upload_service.upload_file(&temp_file_str, None, None).await {
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(e) => {
|
||||||
|
// 清理临时文件
|
||||||
|
let _ = tokio::fs::remove_file(&temp_file_path).await;
|
||||||
|
error!("视频上传失败: {} - {}", temp_file_str, e);
|
||||||
|
return Err(anyhow!("视频上传失败: {}", e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 清理临时文件
|
||||||
|
if let Err(e) = tokio::fs::remove_file(&temp_file_path).await {
|
||||||
|
warn!("清理临时文件失败: {} - {}", temp_file_str, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !upload_result.success {
|
||||||
|
let error_msg = upload_result.error_message.unwrap_or_default();
|
||||||
|
error!("视频上传失败: {}", error_msg);
|
||||||
|
return Err(anyhow!("视频上传失败: {}", error_msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
let s3_url = upload_result.remote_url
|
||||||
|
.ok_or_else(|| anyhow!("上传成功但未返回S3 URL"))?;
|
||||||
|
|
||||||
|
// 转换S3 URL为CDN URL
|
||||||
|
let cdn_url = Self::convert_s3_to_cdn_url(&s3_url);
|
||||||
|
info!("视频上传成功: {} -> {} -> {}", video_url, s3_url, cdn_url);
|
||||||
|
|
||||||
|
Ok(cdn_url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 下载视频文件到本地
|
||||||
|
async fn download_video_to_file(&self, video_url: &str, file_path: &str) -> Result<()> {
|
||||||
|
// 创建带有防盗链绕过的HTTP客户端
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||||
|
.timeout(std::time::Duration::from_secs(300)) // 5分钟超时
|
||||||
|
.build()
|
||||||
|
.map_err(|e| anyhow!("创建HTTP客户端失败: {}", e))?;
|
||||||
|
|
||||||
|
// 构建请求,添加必要的头部信息绕过防盗链
|
||||||
|
let request = client
|
||||||
|
.get(video_url)
|
||||||
|
.header("Referer", "https://www.volcengine.com/")
|
||||||
|
.header("Origin", "https://www.volcengine.com")
|
||||||
|
.header("Accept", "video/webm,video/ogg,video/*;q=0.9,application/ogg;q=0.7,audio/*;q=0.6,*/*;q=0.5")
|
||||||
|
.header("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
|
||||||
|
.header("Cache-Control", "no-cache")
|
||||||
|
.header("Pragma", "no-cache")
|
||||||
|
.header("Sec-Fetch-Dest", "video")
|
||||||
|
.header("Sec-Fetch-Mode", "no-cors")
|
||||||
|
.header("Sec-Fetch-Site", "cross-site");
|
||||||
|
|
||||||
|
let response = request.send().await
|
||||||
|
.map_err(|e| anyhow!("请求视频失败: {}", e))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(anyhow!("下载视频失败,HTTP状态码: {}", response.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = response.bytes().await
|
||||||
|
.map_err(|e| anyhow!("读取视频数据失败: {}", e))?;
|
||||||
|
|
||||||
|
tokio::fs::write(file_path, &bytes).await
|
||||||
|
.map_err(|e| anyhow!("保存视频文件失败: {}", e))?;
|
||||||
|
|
||||||
|
info!("视频文件保存成功: {} ({} bytes)", file_path, bytes.len());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将S3 URL转换为可访问的CDN地址
|
||||||
|
fn convert_s3_to_cdn_url(s3_url: &str) -> String {
|
||||||
|
if s3_url.starts_with("s3://ap-northeast-2/modal-media-cache/") {
|
||||||
|
// 将 s3://ap-northeast-2/modal-media-cache/ 替换为 https://cdn.roasmax.cn/
|
||||||
|
s3_url.replace("s3://ap-northeast-2/modal-media-cache/", "https://cdn.roasmax.cn/")
|
||||||
|
} else if s3_url.starts_with("s3://") {
|
||||||
|
// 处理其他 s3:// 格式,转换为通用CDN格式
|
||||||
|
s3_url.replace("s3://", "https://cdn.roasmax.cn/")
|
||||||
|
} else if s3_url.contains("amazonaws.com") {
|
||||||
|
// 如果是完整的S3 HTTPS URL,提取key部分
|
||||||
|
if let Some(key_start) = s3_url.find(".com/") {
|
||||||
|
let key = &s3_url[key_start + 5..];
|
||||||
|
format!("https://cdn.roasmax.cn/{}", key)
|
||||||
|
} else {
|
||||||
|
s3_url.to_string()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 如果不是预期的S3格式,返回原URL
|
||||||
|
s3_url.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 实现Clone trait以支持异步任务
|
||||||
|
impl Clone for VolcanoVideoService {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
Self {
|
||||||
|
database: self.database.clone(),
|
||||||
|
repository: VideoGenerationRecordRepository::new(self.database.clone()),
|
||||||
|
http_client: self.http_client.clone(),
|
||||||
|
cloud_upload_service: CloudUploadService::new(),
|
||||||
|
vol_access_key: self.vol_access_key.clone(),
|
||||||
|
vol_secret_key: self.vol_secret_key.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,3 +26,4 @@ pub mod speech_generation_record;
|
|||||||
pub mod system_voice;
|
pub mod system_voice;
|
||||||
pub mod outfit_image;
|
pub mod outfit_image;
|
||||||
pub mod outfit_photo_generation;
|
pub mod outfit_photo_generation;
|
||||||
|
pub mod video_generation_record;
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// 视频生成状态枚举
|
||||||
|
/// 遵循 Tauri 开发规范的数据模型设计原则
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub enum VideoGenerationStatus {
|
||||||
|
Pending, // 等待处理
|
||||||
|
Processing, // 处理中
|
||||||
|
Completed, // 已完成
|
||||||
|
Failed, // 失败
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VideoGenerationStatus {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
VideoGenerationStatus::Pending => "Pending",
|
||||||
|
VideoGenerationStatus::Processing => "Processing",
|
||||||
|
VideoGenerationStatus::Completed => "Completed",
|
||||||
|
VideoGenerationStatus::Failed => "Failed",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_str(s: &str) -> Result<Self, String> {
|
||||||
|
match s {
|
||||||
|
"Pending" => Ok(VideoGenerationStatus::Pending),
|
||||||
|
"Processing" => Ok(VideoGenerationStatus::Processing),
|
||||||
|
"Completed" => Ok(VideoGenerationStatus::Completed),
|
||||||
|
"Failed" => Ok(VideoGenerationStatus::Failed),
|
||||||
|
_ => Err(format!("未知的视频生成状态: {}", s)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 视频生成记录实体模型
|
||||||
|
/// 遵循 Tauri 开发规范的数据模型设计原则
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct VideoGenerationRecord {
|
||||||
|
pub id: String,
|
||||||
|
pub project_id: Option<String>,
|
||||||
|
pub name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
|
||||||
|
// 输入文件信息
|
||||||
|
pub image_path: Option<String>,
|
||||||
|
pub image_url: Option<String>,
|
||||||
|
pub audio_path: Option<String>,
|
||||||
|
pub audio_url: Option<String>,
|
||||||
|
|
||||||
|
// 生成参数
|
||||||
|
pub prompt: Option<String>,
|
||||||
|
pub negative_prompt: Option<String>,
|
||||||
|
pub duration: i32, // 视频时长(秒)
|
||||||
|
pub fps: i32, // 帧率
|
||||||
|
pub resolution: String, // 分辨率
|
||||||
|
pub style: Option<String>, // 风格参数
|
||||||
|
pub motion_strength: f32, // 运动强度 0.0-1.0
|
||||||
|
|
||||||
|
// 火山云API相关
|
||||||
|
pub vol_task_id: Option<String>, // 火山云任务ID
|
||||||
|
pub vol_request_id: Option<String>, // 火山云请求ID
|
||||||
|
|
||||||
|
// 生成状态和结果
|
||||||
|
pub status: VideoGenerationStatus,
|
||||||
|
pub progress: i32, // 进度百分比 0-100
|
||||||
|
pub result_video_path: Option<String>,
|
||||||
|
pub result_video_url: Option<String>,
|
||||||
|
pub result_thumbnail_path: Option<String>,
|
||||||
|
pub result_thumbnail_url: Option<String>,
|
||||||
|
|
||||||
|
// 错误信息
|
||||||
|
pub error_message: Option<String>,
|
||||||
|
pub error_code: Option<String>,
|
||||||
|
|
||||||
|
// 时间戳
|
||||||
|
pub started_at: Option<DateTime<Utc>>,
|
||||||
|
pub completed_at: Option<DateTime<Utc>>,
|
||||||
|
pub generation_time_ms: Option<i64>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VideoGenerationRecord {
|
||||||
|
/// 创建新的视频生成记录
|
||||||
|
pub fn new(
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
image_url: Option<String>,
|
||||||
|
audio_url: Option<String>,
|
||||||
|
prompt: Option<String>,
|
||||||
|
) -> Self {
|
||||||
|
let now = Utc::now();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
project_id: None,
|
||||||
|
name,
|
||||||
|
description: None,
|
||||||
|
image_path: None,
|
||||||
|
image_url,
|
||||||
|
audio_path: None,
|
||||||
|
audio_url,
|
||||||
|
prompt,
|
||||||
|
negative_prompt: None,
|
||||||
|
duration: 5,
|
||||||
|
fps: 24,
|
||||||
|
resolution: "1080p".to_string(),
|
||||||
|
style: None,
|
||||||
|
motion_strength: 0.5,
|
||||||
|
vol_task_id: None,
|
||||||
|
vol_request_id: None,
|
||||||
|
status: VideoGenerationStatus::Pending,
|
||||||
|
progress: 0,
|
||||||
|
result_video_path: None,
|
||||||
|
result_video_url: None,
|
||||||
|
result_thumbnail_path: None,
|
||||||
|
result_thumbnail_url: None,
|
||||||
|
error_message: None,
|
||||||
|
error_code: None,
|
||||||
|
started_at: None,
|
||||||
|
completed_at: None,
|
||||||
|
generation_time_ms: None,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 标记为开始处理
|
||||||
|
pub fn mark_as_processing(&mut self, vol_task_id: Option<String>) {
|
||||||
|
self.status = VideoGenerationStatus::Processing;
|
||||||
|
self.vol_task_id = vol_task_id;
|
||||||
|
self.started_at = Some(Utc::now());
|
||||||
|
self.updated_at = Utc::now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 标记为完成
|
||||||
|
pub fn mark_as_completed(&mut self, result_video_url: String, result_thumbnail_url: Option<String>) {
|
||||||
|
self.status = VideoGenerationStatus::Completed;
|
||||||
|
self.result_video_url = Some(result_video_url);
|
||||||
|
self.result_thumbnail_url = result_thumbnail_url;
|
||||||
|
self.progress = 100;
|
||||||
|
self.completed_at = Some(Utc::now());
|
||||||
|
|
||||||
|
if let Some(started) = self.started_at {
|
||||||
|
self.generation_time_ms = Some((Utc::now() - started).num_milliseconds());
|
||||||
|
}
|
||||||
|
|
||||||
|
self.updated_at = Utc::now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 标记为失败
|
||||||
|
pub fn mark_as_failed(&mut self, error_message: String, error_code: Option<String>) {
|
||||||
|
self.status = VideoGenerationStatus::Failed;
|
||||||
|
self.error_message = Some(error_message);
|
||||||
|
self.error_code = error_code;
|
||||||
|
self.completed_at = Some(Utc::now());
|
||||||
|
|
||||||
|
if let Some(started) = self.started_at {
|
||||||
|
self.generation_time_ms = Some((Utc::now() - started).num_milliseconds());
|
||||||
|
}
|
||||||
|
|
||||||
|
self.updated_at = Utc::now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新进度
|
||||||
|
pub fn update_progress(&mut self, progress: i32) {
|
||||||
|
self.progress = progress.clamp(0, 100);
|
||||||
|
self.updated_at = Utc::now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查是否已完成(成功或失败)
|
||||||
|
pub fn is_finished(&self) -> bool {
|
||||||
|
matches!(self.status, VideoGenerationStatus::Completed | VideoGenerationStatus::Failed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查是否成功完成
|
||||||
|
pub fn is_successful(&self) -> bool {
|
||||||
|
self.status == VideoGenerationStatus::Completed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取状态显示文本
|
||||||
|
pub fn get_status_text(&self) -> &'static str {
|
||||||
|
match self.status {
|
||||||
|
VideoGenerationStatus::Pending => "等待处理",
|
||||||
|
VideoGenerationStatus::Processing => "处理中",
|
||||||
|
VideoGenerationStatus::Completed => "已完成",
|
||||||
|
VideoGenerationStatus::Failed => "失败",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取进度显示文本
|
||||||
|
pub fn get_progress_text(&self) -> String {
|
||||||
|
match self.status {
|
||||||
|
VideoGenerationStatus::Pending => "等待开始".to_string(),
|
||||||
|
VideoGenerationStatus::Processing => format!("{}%", self.progress),
|
||||||
|
VideoGenerationStatus::Completed => "100%".to_string(),
|
||||||
|
VideoGenerationStatus::Failed => "失败".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 视频生成记录创建请求
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CreateVideoGenerationRequest {
|
||||||
|
pub name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub image_url: Option<String>,
|
||||||
|
pub audio_url: Option<String>,
|
||||||
|
pub prompt: Option<String>,
|
||||||
|
pub negative_prompt: Option<String>,
|
||||||
|
pub duration: Option<i32>,
|
||||||
|
pub fps: Option<i32>,
|
||||||
|
pub resolution: Option<String>,
|
||||||
|
pub style: Option<String>,
|
||||||
|
pub motion_strength: Option<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 视频生成记录查询参数
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct VideoGenerationQuery {
|
||||||
|
pub project_id: Option<String>,
|
||||||
|
pub status: Option<VideoGenerationStatus>,
|
||||||
|
pub limit: Option<i32>,
|
||||||
|
pub offset: Option<i32>,
|
||||||
|
pub order_by: Option<String>, // created_at, updated_at, name
|
||||||
|
pub order_desc: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for VideoGenerationQuery {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
project_id: None,
|
||||||
|
status: None,
|
||||||
|
limit: Some(50),
|
||||||
|
offset: Some(0),
|
||||||
|
order_by: Some("created_at".to_string()),
|
||||||
|
order_desc: Some(true),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -235,7 +235,6 @@ impl ConversationRepository {
|
|||||||
// 使用专用的只读连接,避免与写操作竞争
|
// 使用专用的只读连接,避免与写操作竞争
|
||||||
match self.database.try_get_read_connection() {
|
match self.database.try_get_read_connection() {
|
||||||
Some(conn) => {
|
Some(conn) => {
|
||||||
println!("✅ 成功获取只读连接");
|
|
||||||
self.execute_history_query(&conn, query)
|
self.execute_history_query(&conn, query)
|
||||||
},
|
},
|
||||||
None => {
|
None => {
|
||||||
|
|||||||
@@ -18,3 +18,4 @@ pub mod image_generation_repository;
|
|||||||
pub mod system_voice_repository;
|
pub mod system_voice_repository;
|
||||||
pub mod outfit_image_repository;
|
pub mod outfit_image_repository;
|
||||||
pub mod outfit_photo_generation_repository;
|
pub mod outfit_photo_generation_repository;
|
||||||
|
pub mod video_generation_record_repository;
|
||||||
|
|||||||
@@ -95,13 +95,11 @@ impl ModelRepository {
|
|||||||
/// 根据ID获取模特
|
/// 根据ID获取模特
|
||||||
/// 使用读写分离避免嵌套锁问题
|
/// 使用读写分离避免嵌套锁问题
|
||||||
pub fn get_by_id(&self, id: &str) -> Result<Option<Model>> {
|
pub fn get_by_id(&self, id: &str) -> Result<Option<Model>> {
|
||||||
println!("get_by_id 开始执行,model_id: {}", id);
|
|
||||||
|
|
||||||
// 首先获取模特基本信息(使用只读连接)
|
// 首先获取模特基本信息(使用只读连接)
|
||||||
let model = {
|
let model = {
|
||||||
match self.database.try_get_read_connection() {
|
match self.database.try_get_read_connection() {
|
||||||
Some(conn) => {
|
Some(conn) => {
|
||||||
println!("get_by_id 使用只读连接获取基本信息");
|
|
||||||
|
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT id, name, stage_name, gender, age, height, weight, measurements,
|
"SELECT id, name, stage_name, gender, age, height, weight, measurements,
|
||||||
@@ -129,12 +127,9 @@ impl ModelRepository {
|
|||||||
|
|
||||||
// 如果找到模特,加载照片信息(此时已释放了基本信息查询的锁)
|
// 如果找到模特,加载照片信息(此时已释放了基本信息查询的锁)
|
||||||
if let Some(mut model) = model {
|
if let Some(mut model) = model {
|
||||||
println!("get_by_id 开始加载照片信息");
|
|
||||||
model.photos = self.get_photos(&model.id)?;
|
model.photos = self.get_photos(&model.id)?;
|
||||||
println!("get_by_id 执行完成");
|
|
||||||
Ok(Some(model))
|
Ok(Some(model))
|
||||||
} else {
|
} else {
|
||||||
println!("get_by_id 未找到模特");
|
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -498,7 +493,6 @@ impl ModelRepository {
|
|||||||
/// 获取模特照片
|
/// 获取模特照片
|
||||||
/// 使用专用只读连接,避免与写操作的锁竞争
|
/// 使用专用只读连接,避免与写操作的锁竞争
|
||||||
pub fn get_photos(&self, model_id: &str) -> Result<Vec<ModelPhoto>> {
|
pub fn get_photos(&self, model_id: &str) -> Result<Vec<ModelPhoto>> {
|
||||||
println!("get_photos 开始执行,model_id: {}", model_id);
|
|
||||||
|
|
||||||
// 使用专用的只读连接,避免与写操作竞争
|
// 使用专用的只读连接,避免与写操作竞争
|
||||||
match self.database.get_best_read_connection() {
|
match self.database.get_best_read_connection() {
|
||||||
@@ -517,7 +511,6 @@ impl ModelRepository {
|
|||||||
|
|
||||||
/// 执行照片查询的具体实现
|
/// 执行照片查询的具体实现
|
||||||
fn execute_photo_query(&self, conn: &Connection, model_id: &str) -> Result<Vec<ModelPhoto>> {
|
fn execute_photo_query(&self, conn: &Connection, model_id: &str) -> Result<Vec<ModelPhoto>> {
|
||||||
println!("get_photos 开始准备SQL语句");
|
|
||||||
|
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT id, model_id, file_path, file_name, file_size, photo_type,
|
"SELECT id, model_id, file_path, file_name, file_size, photo_type,
|
||||||
@@ -527,7 +520,6 @@ impl ModelRepository {
|
|||||||
println!("get_photos SQL 语句准备成功");
|
println!("get_photos SQL 语句准备成功");
|
||||||
|
|
||||||
let photo_iter = stmt.query_map([model_id], |row| self.row_to_photo(row))?;
|
let photo_iter = stmt.query_map([model_id], |row| self.row_to_photo(row))?;
|
||||||
println!("get_photos 查询执行成功");
|
|
||||||
|
|
||||||
let mut photos = Vec::new();
|
let mut photos = Vec::new();
|
||||||
for (index, photo_result) in photo_iter.enumerate() {
|
for (index, photo_result) in photo_iter.enumerate() {
|
||||||
@@ -545,7 +537,6 @@ impl ModelRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("get_photos 查询处理完成,共 {} 张照片", photos.len());
|
|
||||||
Ok(photos)
|
Ok(photos)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -735,10 +726,7 @@ impl ModelRepository {
|
|||||||
|
|
||||||
/// 将数据库行转换为照片对象
|
/// 将数据库行转换为照片对象
|
||||||
fn row_to_photo(&self, row: &Row) -> Result<ModelPhoto> {
|
fn row_to_photo(&self, row: &Row) -> Result<ModelPhoto> {
|
||||||
println!("row_to_photo 开始执行");
|
|
||||||
|
|
||||||
let photo_type_str: String = row.get("photo_type")?;
|
let photo_type_str: String = row.get("photo_type")?;
|
||||||
println!("row_to_photo 获取 photo_type: {}", photo_type_str);
|
|
||||||
|
|
||||||
let photo_type: PhotoType = serde_json::from_str(&photo_type_str).map_err(|e| {
|
let photo_type: PhotoType = serde_json::from_str(&photo_type_str).map_err(|e| {
|
||||||
println!("row_to_photo photo_type 解析失败: {}", e);
|
println!("row_to_photo photo_type 解析失败: {}", e);
|
||||||
|
|||||||
@@ -0,0 +1,320 @@
|
|||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use chrono::DateTime;
|
||||||
|
use rusqlite::{params, Row};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::data::models::video_generation_record::{
|
||||||
|
VideoGenerationRecord, VideoGenerationStatus, VideoGenerationQuery
|
||||||
|
};
|
||||||
|
use crate::infrastructure::database::Database;
|
||||||
|
|
||||||
|
/// 视频生成记录数据仓库
|
||||||
|
/// 遵循 Tauri 开发规范的数据访问层设计原则
|
||||||
|
pub struct VideoGenerationRecordRepository {
|
||||||
|
database: Arc<Database>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VideoGenerationRecordRepository {
|
||||||
|
/// 创建新的视频生成记录仓库实例
|
||||||
|
pub fn new(database: Arc<Database>) -> Self {
|
||||||
|
Self { database }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建视频生成记录
|
||||||
|
pub async fn create(&self, record: &VideoGenerationRecord) -> Result<()> {
|
||||||
|
if !self.database.has_pool() {
|
||||||
|
return Err(anyhow!("连接池未启用,无法安全执行数据库操作"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let pooled_conn = self
|
||||||
|
.database
|
||||||
|
.acquire_from_pool()
|
||||||
|
.map_err(|e| anyhow!("获取连接池连接失败: {}", e))?;
|
||||||
|
|
||||||
|
pooled_conn.execute(
|
||||||
|
"INSERT INTO video_generation_records (
|
||||||
|
id, project_id, name, description,
|
||||||
|
image_path, image_url, audio_path, audio_url,
|
||||||
|
prompt, negative_prompt, duration, fps, resolution, style, motion_strength,
|
||||||
|
vol_task_id, vol_request_id,
|
||||||
|
status, progress, result_video_path, result_video_url,
|
||||||
|
result_thumbnail_path, result_thumbnail_url,
|
||||||
|
error_message, error_code,
|
||||||
|
started_at, completed_at, generation_time_ms,
|
||||||
|
created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
?1, ?2, ?3, ?4,
|
||||||
|
?5, ?6, ?7, ?8,
|
||||||
|
?9, ?10, ?11, ?12, ?13, ?14, ?15,
|
||||||
|
?16, ?17,
|
||||||
|
?18, ?19, ?20, ?21,
|
||||||
|
?22, ?23,
|
||||||
|
?24, ?25,
|
||||||
|
?26, ?27, ?28,
|
||||||
|
?29, ?30
|
||||||
|
)",
|
||||||
|
params![
|
||||||
|
record.id,
|
||||||
|
record.project_id,
|
||||||
|
record.name,
|
||||||
|
record.description,
|
||||||
|
record.image_path,
|
||||||
|
record.image_url,
|
||||||
|
record.audio_path,
|
||||||
|
record.audio_url,
|
||||||
|
record.prompt,
|
||||||
|
record.negative_prompt,
|
||||||
|
record.duration,
|
||||||
|
record.fps,
|
||||||
|
record.resolution,
|
||||||
|
record.style,
|
||||||
|
record.motion_strength,
|
||||||
|
record.vol_task_id,
|
||||||
|
record.vol_request_id,
|
||||||
|
record.status.as_str(),
|
||||||
|
record.progress,
|
||||||
|
record.result_video_path,
|
||||||
|
record.result_video_url,
|
||||||
|
record.result_thumbnail_path,
|
||||||
|
record.result_thumbnail_url,
|
||||||
|
record.error_message,
|
||||||
|
record.error_code,
|
||||||
|
record.started_at.map(|dt| dt.timestamp()),
|
||||||
|
record.completed_at.map(|dt| dt.timestamp()),
|
||||||
|
record.generation_time_ms,
|
||||||
|
record.created_at.timestamp(),
|
||||||
|
record.updated_at.timestamp(),
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新视频生成记录
|
||||||
|
pub async fn update(&self, record: &VideoGenerationRecord) -> Result<()> {
|
||||||
|
if !self.database.has_pool() {
|
||||||
|
return Err(anyhow!("连接池未启用,无法安全执行数据库操作"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let pooled_conn = self
|
||||||
|
.database
|
||||||
|
.acquire_from_pool()
|
||||||
|
.map_err(|e| anyhow!("获取连接池连接失败: {}", e))?;
|
||||||
|
|
||||||
|
pooled_conn.execute(
|
||||||
|
"UPDATE video_generation_records SET
|
||||||
|
project_id = ?2, name = ?3, description = ?4,
|
||||||
|
image_path = ?5, image_url = ?6, audio_path = ?7, audio_url = ?8,
|
||||||
|
prompt = ?9, negative_prompt = ?10, duration = ?11, fps = ?12,
|
||||||
|
resolution = ?13, style = ?14, motion_strength = ?15,
|
||||||
|
vol_task_id = ?16, vol_request_id = ?17,
|
||||||
|
status = ?18, progress = ?19, result_video_path = ?20, result_video_url = ?21,
|
||||||
|
result_thumbnail_path = ?22, result_thumbnail_url = ?23,
|
||||||
|
error_message = ?24, error_code = ?25,
|
||||||
|
started_at = ?26, completed_at = ?27, generation_time_ms = ?28,
|
||||||
|
updated_at = ?29
|
||||||
|
WHERE id = ?1",
|
||||||
|
params![
|
||||||
|
record.id,
|
||||||
|
record.project_id,
|
||||||
|
record.name,
|
||||||
|
record.description,
|
||||||
|
record.image_path,
|
||||||
|
record.image_url,
|
||||||
|
record.audio_path,
|
||||||
|
record.audio_url,
|
||||||
|
record.prompt,
|
||||||
|
record.negative_prompt,
|
||||||
|
record.duration,
|
||||||
|
record.fps,
|
||||||
|
record.resolution,
|
||||||
|
record.style,
|
||||||
|
record.motion_strength,
|
||||||
|
record.vol_task_id,
|
||||||
|
record.vol_request_id,
|
||||||
|
record.status.as_str(),
|
||||||
|
record.progress,
|
||||||
|
record.result_video_path,
|
||||||
|
record.result_video_url,
|
||||||
|
record.result_thumbnail_path,
|
||||||
|
record.result_thumbnail_url,
|
||||||
|
record.error_message,
|
||||||
|
record.error_code,
|
||||||
|
record.started_at.map(|dt| dt.timestamp()),
|
||||||
|
record.completed_at.map(|dt| dt.timestamp()),
|
||||||
|
record.generation_time_ms,
|
||||||
|
record.updated_at.timestamp(),
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 根据ID获取视频生成记录
|
||||||
|
pub async fn get_by_id(&self, id: &str) -> Result<Option<VideoGenerationRecord>> {
|
||||||
|
if !self.database.has_pool() {
|
||||||
|
return Err(anyhow!("连接池未启用,无法安全执行数据库操作"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let pooled_conn = self
|
||||||
|
.database
|
||||||
|
.acquire_from_pool()
|
||||||
|
.map_err(|e| anyhow!("获取连接池连接失败: {}", e))?;
|
||||||
|
|
||||||
|
let result = pooled_conn.query_row(
|
||||||
|
"SELECT id, project_id, name, description,
|
||||||
|
image_path, image_url, audio_path, audio_url,
|
||||||
|
prompt, negative_prompt, duration, fps, resolution, style, motion_strength,
|
||||||
|
vol_task_id, vol_request_id,
|
||||||
|
status, progress, result_video_path, result_video_url,
|
||||||
|
result_thumbnail_path, result_thumbnail_url,
|
||||||
|
error_message, error_code,
|
||||||
|
started_at, completed_at, generation_time_ms,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM video_generation_records WHERE id = ?1",
|
||||||
|
params![id],
|
||||||
|
|row| self.row_to_record(row),
|
||||||
|
);
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(record) => Ok(Some(record)),
|
||||||
|
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取视频生成记录列表
|
||||||
|
pub async fn get_list(&self, query: VideoGenerationQuery) -> Result<Vec<VideoGenerationRecord>> {
|
||||||
|
if !self.database.has_pool() {
|
||||||
|
return Err(anyhow!("连接池未启用,无法安全执行数据库操作"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let pooled_conn = self
|
||||||
|
.database
|
||||||
|
.acquire_from_pool()
|
||||||
|
.map_err(|e| anyhow!("获取连接池连接失败: {}", e))?;
|
||||||
|
|
||||||
|
let mut sql = "SELECT id, project_id, name, description,
|
||||||
|
image_path, image_url, audio_path, audio_url,
|
||||||
|
prompt, negative_prompt, duration, fps, resolution, style, motion_strength,
|
||||||
|
vol_task_id, vol_request_id,
|
||||||
|
status, progress, result_video_path, result_video_url,
|
||||||
|
result_thumbnail_path, result_thumbnail_url,
|
||||||
|
error_message, error_code,
|
||||||
|
started_at, completed_at, generation_time_ms,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM video_generation_records WHERE 1=1".to_string();
|
||||||
|
|
||||||
|
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
||||||
|
|
||||||
|
// 添加查询条件
|
||||||
|
if let Some(project_id) = &query.project_id {
|
||||||
|
sql.push_str(" AND project_id = ?");
|
||||||
|
params.push(Box::new(project_id.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(status) = &query.status {
|
||||||
|
sql.push_str(" AND status = ?");
|
||||||
|
params.push(Box::new(status.as_str()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加排序
|
||||||
|
if let Some(order_by) = &query.order_by {
|
||||||
|
sql.push_str(&format!(" ORDER BY {}", order_by));
|
||||||
|
if query.order_desc.unwrap_or(false) {
|
||||||
|
sql.push_str(" DESC");
|
||||||
|
} else {
|
||||||
|
sql.push_str(" ASC");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加分页
|
||||||
|
if let Some(limit) = query.limit {
|
||||||
|
sql.push_str(&format!(" LIMIT {}", limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(offset) = query.offset {
|
||||||
|
sql.push_str(&format!(" OFFSET {}", offset));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut stmt = pooled_conn.prepare(&sql)?;
|
||||||
|
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
||||||
|
|
||||||
|
let rows = stmt.query_map(¶m_refs[..], |row| self.row_to_record(row))?;
|
||||||
|
|
||||||
|
let mut records = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
records.push(row?);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(records)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除视频生成记录
|
||||||
|
pub async fn delete(&self, id: &str) -> Result<()> {
|
||||||
|
if !self.database.has_pool() {
|
||||||
|
return Err(anyhow!("连接池未启用,无法安全执行数据库操作"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let pooled_conn = self
|
||||||
|
.database
|
||||||
|
.acquire_from_pool()
|
||||||
|
.map_err(|e| anyhow!("获取连接池连接失败: {}", e))?;
|
||||||
|
|
||||||
|
pooled_conn.execute(
|
||||||
|
"DELETE FROM video_generation_records WHERE id = ?1",
|
||||||
|
params![id],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将数据库行转换为VideoGenerationRecord
|
||||||
|
fn row_to_record(&self, row: &Row) -> rusqlite::Result<VideoGenerationRecord> {
|
||||||
|
let status_str: String = row.get("status")?;
|
||||||
|
let status = VideoGenerationStatus::from_str(&status_str)
|
||||||
|
.map_err(|e| rusqlite::Error::FromSqlConversionFailure(
|
||||||
|
row.as_ref().column_index("status").unwrap(),
|
||||||
|
rusqlite::types::Type::Text,
|
||||||
|
Box::new(std::io::Error::new(std::io::ErrorKind::InvalidData, e))
|
||||||
|
))?;
|
||||||
|
|
||||||
|
let started_at: Option<i64> = row.get("started_at")?;
|
||||||
|
let completed_at: Option<i64> = row.get("completed_at")?;
|
||||||
|
let created_at: i64 = row.get("created_at")?;
|
||||||
|
let updated_at: i64 = row.get("updated_at")?;
|
||||||
|
|
||||||
|
Ok(VideoGenerationRecord {
|
||||||
|
id: row.get("id")?,
|
||||||
|
project_id: row.get("project_id")?,
|
||||||
|
name: row.get("name")?,
|
||||||
|
description: row.get("description")?,
|
||||||
|
image_path: row.get("image_path")?,
|
||||||
|
image_url: row.get("image_url")?,
|
||||||
|
audio_path: row.get("audio_path")?,
|
||||||
|
audio_url: row.get("audio_url")?,
|
||||||
|
prompt: row.get("prompt")?,
|
||||||
|
negative_prompt: row.get("negative_prompt")?,
|
||||||
|
duration: row.get("duration")?,
|
||||||
|
fps: row.get("fps")?,
|
||||||
|
resolution: row.get("resolution")?,
|
||||||
|
style: row.get("style")?,
|
||||||
|
motion_strength: row.get("motion_strength")?,
|
||||||
|
vol_task_id: row.get("vol_task_id")?,
|
||||||
|
vol_request_id: row.get("vol_request_id")?,
|
||||||
|
status,
|
||||||
|
progress: row.get("progress")?,
|
||||||
|
result_video_path: row.get("result_video_path")?,
|
||||||
|
result_video_url: row.get("result_video_url")?,
|
||||||
|
result_thumbnail_path: row.get("result_thumbnail_path")?,
|
||||||
|
result_thumbnail_url: row.get("result_thumbnail_url")?,
|
||||||
|
error_message: row.get("error_message")?,
|
||||||
|
error_code: row.get("error_code")?,
|
||||||
|
started_at: started_at.map(|ts| DateTime::from_timestamp(ts, 0).unwrap_or_default()),
|
||||||
|
completed_at: completed_at.map(|ts| DateTime::from_timestamp(ts, 0).unwrap_or_default()),
|
||||||
|
generation_time_ms: row.get("generation_time_ms")?,
|
||||||
|
created_at: DateTime::from_timestamp(created_at, 0).unwrap_or_default(),
|
||||||
|
updated_at: DateTime::from_timestamp(updated_at, 0).unwrap_or_default(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -245,7 +245,6 @@ impl ConnectionPool {
|
|||||||
|
|
||||||
if let Some(index) = found_index {
|
if let Some(index) = found_index {
|
||||||
let pooled_conn = connections.remove(index).unwrap();
|
let pooled_conn = connections.remove(index).unwrap();
|
||||||
println!("从连接池获取现有连接,剩余连接数: {}", connections.len());
|
|
||||||
return Ok(Some(pooled_conn.connection));
|
return Ok(Some(pooled_conn.connection));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -221,22 +221,6 @@ impl MigrationManager {
|
|||||||
down_sql: Some(include_str!("migrations/023_create_outfit_images_tables_down.sql").to_string()),
|
down_sql: Some(include_str!("migrations/023_create_outfit_images_tables_down.sql").to_string()),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 迁移 25: 创建声音克隆记录表
|
|
||||||
self.add_migration(Migration {
|
|
||||||
version: 25,
|
|
||||||
description: "创建声音克隆记录表".to_string(),
|
|
||||||
up_sql: include_str!("migrations/023_create_voice_clone_table.sql").to_string(),
|
|
||||||
down_sql: Some(include_str!("migrations/023_create_voice_clone_table_down.sql").to_string()),
|
|
||||||
});
|
|
||||||
|
|
||||||
// 迁移 26: 创建系统音色表
|
|
||||||
self.add_migration(Migration {
|
|
||||||
version: 26,
|
|
||||||
description: "创建系统音色表".to_string(),
|
|
||||||
up_sql: include_str!("migrations/025_create_system_voices_table.sql").to_string(),
|
|
||||||
down_sql: Some(include_str!("migrations/025_create_system_voices_table_down.sql").to_string()),
|
|
||||||
});
|
|
||||||
|
|
||||||
// 迁移 24: 创建语音生成记录表
|
// 迁移 24: 创建语音生成记录表
|
||||||
self.add_migration(Migration {
|
self.add_migration(Migration {
|
||||||
version: 24,
|
version: 24,
|
||||||
@@ -245,12 +229,37 @@ impl MigrationManager {
|
|||||||
down_sql: None, // 暂时不提供回滚脚本
|
down_sql: None, // 暂时不提供回滚脚本
|
||||||
});
|
});
|
||||||
|
|
||||||
// 迁移 25: 创建穿搭照片生成记录表
|
// 迁移 25: 创建声音克隆记录表
|
||||||
self.add_migration(Migration {
|
self.add_migration(Migration {
|
||||||
version: 25,
|
version: 25,
|
||||||
|
description: "创建声音克隆记录表".to_string(),
|
||||||
|
up_sql: include_str!("migrations/025_create_voice_clone_table.sql").to_string(),
|
||||||
|
down_sql: Some(include_str!("migrations/025_create_voice_clone_table_down.sql").to_string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 迁移 26: 创建系统音色表
|
||||||
|
self.add_migration(Migration {
|
||||||
|
version: 25,
|
||||||
|
description: "创建系统音色表".to_string(),
|
||||||
|
up_sql: include_str!("migrations/025_create_system_voices_table.sql").to_string(),
|
||||||
|
down_sql: Some(include_str!("migrations/025_create_system_voices_table_down.sql").to_string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// 迁移 26: 创建穿搭照片生成记录表
|
||||||
|
self.add_migration(Migration {
|
||||||
|
version: 28,
|
||||||
description: "创建穿搭照片生成记录表".to_string(),
|
description: "创建穿搭照片生成记录表".to_string(),
|
||||||
up_sql: include_str!("migrations/025_create_outfit_photo_generations_table.sql").to_string(),
|
up_sql: include_str!("migrations/026_create_outfit_photo_generations_table.sql").to_string(),
|
||||||
down_sql: Some(include_str!("migrations/025_create_outfit_photo_generations_table_down.sql").to_string()),
|
down_sql: Some(include_str!("migrations/026_create_outfit_photo_generations_table_down.sql").to_string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 迁移 27: 创建视频生成记录表
|
||||||
|
self.add_migration(Migration {
|
||||||
|
version: 27,
|
||||||
|
description: "创建视频生成记录表".to_string(),
|
||||||
|
up_sql: include_str!("migrations/027_create_video_generation_records_table.sql").to_string(),
|
||||||
|
down_sql: Some(include_str!("migrations/027_create_video_generation_records_table_down.sql").to_string()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
-- 创建视频生成记录表
|
||||||
|
-- 遵循 Tauri 开发规范的数据库设计原则
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS video_generation_records (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
project_id TEXT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
|
||||||
|
-- 输入文件信息
|
||||||
|
image_path TEXT,
|
||||||
|
image_url TEXT,
|
||||||
|
audio_path TEXT,
|
||||||
|
audio_url TEXT,
|
||||||
|
|
||||||
|
-- 生成参数
|
||||||
|
prompt TEXT,
|
||||||
|
negative_prompt TEXT,
|
||||||
|
duration INTEGER DEFAULT 5, -- 视频时长(秒)
|
||||||
|
fps INTEGER DEFAULT 24, -- 帧率
|
||||||
|
resolution TEXT DEFAULT '1080p', -- 分辨率
|
||||||
|
style TEXT, -- 风格参数
|
||||||
|
motion_strength REAL DEFAULT 0.5, -- 运动强度 0.0-1.0
|
||||||
|
|
||||||
|
-- 火山云API相关
|
||||||
|
vol_task_id TEXT, -- 火山云任务ID
|
||||||
|
vol_request_id TEXT, -- 火山云请求ID
|
||||||
|
|
||||||
|
-- 生成状态和结果
|
||||||
|
status TEXT NOT NULL DEFAULT 'Pending', -- Pending, Processing, Completed, Failed
|
||||||
|
progress INTEGER DEFAULT 0, -- 进度百分比 0-100
|
||||||
|
result_video_path TEXT,
|
||||||
|
result_video_url TEXT,
|
||||||
|
result_thumbnail_path TEXT,
|
||||||
|
result_thumbnail_url TEXT,
|
||||||
|
|
||||||
|
-- 错误信息
|
||||||
|
error_message TEXT,
|
||||||
|
error_code TEXT,
|
||||||
|
|
||||||
|
-- 时间戳
|
||||||
|
started_at INTEGER,
|
||||||
|
completed_at INTEGER,
|
||||||
|
generation_time_ms INTEGER,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
|
||||||
|
-- 外键约束
|
||||||
|
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 创建索引
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_video_generation_records_project_id ON video_generation_records (project_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_video_generation_records_status ON video_generation_records (status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_video_generation_records_created_at ON video_generation_records (created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_video_generation_records_vol_task_id ON video_generation_records (vol_task_id);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- 回滚视频生成记录表创建
|
||||||
|
-- 遵循 Tauri 开发规范的数据库设计原则
|
||||||
|
|
||||||
|
-- 删除索引
|
||||||
|
DROP INDEX IF EXISTS idx_video_generation_records_vol_task_id;
|
||||||
|
DROP INDEX IF EXISTS idx_video_generation_records_created_at;
|
||||||
|
DROP INDEX IF EXISTS idx_video_generation_records_status;
|
||||||
|
DROP INDEX IF EXISTS idx_video_generation_records_project_id;
|
||||||
|
|
||||||
|
-- 删除表
|
||||||
|
DROP TABLE IF EXISTS video_generation_records;
|
||||||
@@ -490,7 +490,17 @@ pub fn run() {
|
|||||||
commands::outfit_photo_generation_commands::delete_outfit_photo_generation,
|
commands::outfit_photo_generation_commands::delete_outfit_photo_generation,
|
||||||
commands::outfit_photo_generation_commands::regenerate_outfit_photo,
|
commands::outfit_photo_generation_commands::regenerate_outfit_photo,
|
||||||
commands::outfit_photo_generation_commands::cancel_outfit_photo_generation,
|
commands::outfit_photo_generation_commands::cancel_outfit_photo_generation,
|
||||||
commands::outfit_photo_generation_commands::get_workflow_list
|
commands::outfit_photo_generation_commands::get_workflow_list,
|
||||||
|
// 火山云视频生成相关命令
|
||||||
|
commands::volcano_video_commands::create_volcano_video_generation,
|
||||||
|
commands::volcano_video_commands::get_volcano_video_generations,
|
||||||
|
commands::volcano_video_commands::get_volcano_video_generation_by_id,
|
||||||
|
commands::volcano_video_commands::delete_volcano_video_generation,
|
||||||
|
commands::volcano_video_commands::batch_delete_volcano_video_generations,
|
||||||
|
commands::volcano_video_commands::download_volcano_video,
|
||||||
|
commands::volcano_video_commands::download_video_to_directory,
|
||||||
|
commands::volcano_video_commands::batch_download_volcano_videos,
|
||||||
|
commands::volcano_video_commands::get_video_stream_base64
|
||||||
])
|
])
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
// 初始化日志系统
|
// 初始化日志系统
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ pub async fn get_directory_settings() -> Result<DirectorySettingsResponse, Strin
|
|||||||
|
|
||||||
match service.get_directory_settings() {
|
match service.get_directory_settings() {
|
||||||
Ok(settings) => {
|
Ok(settings) => {
|
||||||
info!("成功获取目录设置");
|
|
||||||
Ok(DirectorySettingsResponse::from(settings))
|
Ok(DirectorySettingsResponse::from(settings))
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -70,7 +69,6 @@ pub async fn get_directory_setting(setting_type: String) -> Result<Option<String
|
|||||||
|
|
||||||
match service.get_directory_setting(setting_type_enum) {
|
match service.get_directory_setting(setting_type_enum) {
|
||||||
Ok(setting) => {
|
Ok(setting) => {
|
||||||
info!("成功获取目录设置 {}: {:?}", setting_type, setting);
|
|
||||||
Ok(setting)
|
Ok(setting)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|||||||
@@ -39,3 +39,4 @@ pub mod outfit_image_commands;
|
|||||||
pub mod outfit_photo_generation_commands;
|
pub mod outfit_photo_generation_commands;
|
||||||
pub mod workflow_management_commands;
|
pub mod workflow_management_commands;
|
||||||
pub mod error_handling_commands;
|
pub mod error_handling_commands;
|
||||||
|
pub mod volcano_video_commands;
|
||||||
|
|||||||
@@ -44,8 +44,6 @@ pub async fn get_model_dashboard_stats(
|
|||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
model_id: String,
|
model_id: String,
|
||||||
) -> Result<ModelDashboardStats, String> {
|
) -> Result<ModelDashboardStats, String> {
|
||||||
println!("📊 获取模特个人看板统计信息: {}", model_id);
|
|
||||||
|
|
||||||
// 获取数据库连接
|
// 获取数据库连接
|
||||||
let database = state.get_database();
|
let database = state.get_database();
|
||||||
let outfit_repo = OutfitImageRepository::new(database.clone());
|
let outfit_repo = OutfitImageRepository::new(database.clone());
|
||||||
@@ -118,7 +116,6 @@ pub async fn get_outfit_image_records(
|
|||||||
let records = outfit_repo.get_records_by_model_id(&model_id)
|
let records = outfit_repo.get_records_by_model_id(&model_id)
|
||||||
.map_err(|e| format!("获取穿搭图片记录失败: {}", e))?;
|
.map_err(|e| format!("获取穿搭图片记录失败: {}", e))?;
|
||||||
|
|
||||||
println!("✅ 获取到 {} 条穿搭图片记录", records.len());
|
|
||||||
Ok(records)
|
Ok(records)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +127,6 @@ pub async fn get_outfit_image_records_paginated(
|
|||||||
page: u32,
|
page: u32,
|
||||||
page_size: u32,
|
page_size: u32,
|
||||||
) -> Result<OutfitImageRecordsResponse, String> {
|
) -> Result<OutfitImageRecordsResponse, String> {
|
||||||
println!("📋 分页获取模特穿搭图片生成记录: {} (页码: {}, 每页: {})", model_id, page, page_size);
|
|
||||||
|
|
||||||
let database = state.get_database();
|
let database = state.get_database();
|
||||||
let outfit_repo = OutfitImageRepository::new(database);
|
let outfit_repo = OutfitImageRepository::new(database);
|
||||||
@@ -157,8 +153,6 @@ pub async fn get_outfit_image_records_paginated(
|
|||||||
has_more,
|
has_more,
|
||||||
};
|
};
|
||||||
|
|
||||||
println!("✅ 获取到 {} 条穿搭图片记录 (总数: {}, 还有更多: {})",
|
|
||||||
response.records.len(), total_count, has_more);
|
|
||||||
Ok(response)
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,13 +34,11 @@ pub struct SystemVoiceResponse {
|
|||||||
pub async fn get_system_voices(
|
pub async fn get_system_voices(
|
||||||
database: State<'_, Arc<Database>>,
|
database: State<'_, Arc<Database>>,
|
||||||
) -> Result<Vec<SystemVoice>, String> {
|
) -> Result<Vec<SystemVoice>, String> {
|
||||||
info!("获取系统音色列表");
|
|
||||||
|
|
||||||
let repository = SystemVoiceRepository::new(database.inner().clone());
|
let repository = SystemVoiceRepository::new(database.inner().clone());
|
||||||
|
|
||||||
match repository.get_all_active() {
|
match repository.get_all_active() {
|
||||||
Ok(voices) => {
|
Ok(voices) => {
|
||||||
info!("成功获取 {} 个系统音色", voices.len());
|
|
||||||
Ok(voices)
|
Ok(voices)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -63,7 +61,6 @@ pub async fn get_system_voices_by_type(
|
|||||||
|
|
||||||
match repository.get_by_type(voice_type_enum) {
|
match repository.get_by_type(voice_type_enum) {
|
||||||
Ok(voices) => {
|
Ok(voices) => {
|
||||||
info!("成功获取 {} 个 {} 类型的系统音色", voices.len(), voice_type);
|
|
||||||
Ok(voices)
|
Ok(voices)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -85,7 +82,6 @@ pub async fn get_system_voices_by_gender(
|
|||||||
|
|
||||||
match repository.get_by_gender(&gender) {
|
match repository.get_by_gender(&gender) {
|
||||||
Ok(voices) => {
|
Ok(voices) => {
|
||||||
info!("成功获取 {} 个 {} 性别的系统音色", voices.len(), gender);
|
|
||||||
Ok(voices)
|
Ok(voices)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -107,7 +103,6 @@ pub async fn get_system_voices_by_language(
|
|||||||
|
|
||||||
match repository.get_by_language(&language) {
|
match repository.get_by_language(&language) {
|
||||||
Ok(voices) => {
|
Ok(voices) => {
|
||||||
info!("成功获取 {} 个 {} 语言的系统音色", voices.len(), language);
|
|
||||||
Ok(voices)
|
Ok(voices)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -188,7 +183,6 @@ pub async fn get_system_voices_paginated(
|
|||||||
total_pages,
|
total_pages,
|
||||||
};
|
};
|
||||||
|
|
||||||
info!("成功获取分页系统音色,页码: {}, 每页: {}, 总数: {}", page, page_size, total);
|
|
||||||
Ok(response)
|
Ok(response)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -210,11 +204,6 @@ pub async fn get_system_voice_by_id(
|
|||||||
|
|
||||||
match repository.get_by_voice_id(&voice_id) {
|
match repository.get_by_voice_id(&voice_id) {
|
||||||
Ok(voice) => {
|
Ok(voice) => {
|
||||||
if voice.is_some() {
|
|
||||||
info!("成功获取系统音色详情: {}", voice_id);
|
|
||||||
} else {
|
|
||||||
info!("未找到系统音色: {}", voice_id);
|
|
||||||
}
|
|
||||||
Ok(voice)
|
Ok(voice)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -297,6 +286,5 @@ pub async fn get_system_voice_stats(
|
|||||||
"by_gender": gender_stats
|
"by_gender": gender_stats
|
||||||
});
|
});
|
||||||
|
|
||||||
info!("成功获取系统音色统计信息");
|
|
||||||
Ok(stats)
|
Ok(stats)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -660,13 +660,11 @@ pub async fn get_speech_generation_records(
|
|||||||
database: State<'_, Arc<Database>>,
|
database: State<'_, Arc<Database>>,
|
||||||
limit: Option<i32>,
|
limit: Option<i32>,
|
||||||
) -> Result<Vec<SpeechGenerationRecord>, String> {
|
) -> Result<Vec<SpeechGenerationRecord>, String> {
|
||||||
info!("获取语音生成记录列表, limit: {:?}", limit);
|
|
||||||
|
|
||||||
match database.inner().with_connection(|conn| {
|
match database.inner().with_connection(|conn| {
|
||||||
SpeechGenerationRecord::get_all(conn, limit)
|
SpeechGenerationRecord::get_all(conn, limit)
|
||||||
}) {
|
}) {
|
||||||
Ok(records) => {
|
Ok(records) => {
|
||||||
info!("成功获取 {} 条语音生成记录", records.len());
|
|
||||||
Ok(records)
|
Ok(records)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -689,7 +687,6 @@ pub async fn get_speech_generation_records_by_voice_id(
|
|||||||
SpeechGenerationRecord::get_by_voice_id(conn, &voice_id, limit)
|
SpeechGenerationRecord::get_by_voice_id(conn, &voice_id, limit)
|
||||||
}) {
|
}) {
|
||||||
Ok(records) => {
|
Ok(records) => {
|
||||||
info!("成功获取音色 {} 的 {} 条语音生成记录", voice_id, records.len());
|
|
||||||
Ok(records)
|
Ok(records)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|||||||
@@ -0,0 +1,445 @@
|
|||||||
|
use tauri::State;
|
||||||
|
use tracing::{error, info};
|
||||||
|
|
||||||
|
use crate::app_state::AppState;
|
||||||
|
use crate::business::services::volcano_video_service::VolcanoVideoService;
|
||||||
|
use crate::data::models::video_generation_record::{
|
||||||
|
VideoGenerationRecord, CreateVideoGenerationRequest, VideoGenerationQuery
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 创建视频生成任务
|
||||||
|
/// 遵循 Tauri 开发规范的命令设计模式
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn create_volcano_video_generation(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
request: CreateVideoGenerationRequest,
|
||||||
|
) -> Result<VideoGenerationRecord, String> {
|
||||||
|
info!("创建火山云视频生成任务: {}", request.name);
|
||||||
|
|
||||||
|
// 获取数据库连接
|
||||||
|
let database = {
|
||||||
|
let database_guard = state
|
||||||
|
.database
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| format!("获取数据库失败: {}", e))?;
|
||||||
|
database_guard
|
||||||
|
.as_ref()
|
||||||
|
.ok_or("数据库未初始化")?
|
||||||
|
.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建服务实例
|
||||||
|
let service = VolcanoVideoService::new(database);
|
||||||
|
|
||||||
|
// 创建视频生成任务
|
||||||
|
match service.create_video_generation(request).await {
|
||||||
|
Ok(record) => {
|
||||||
|
info!("视频生成任务创建成功: {}", record.id);
|
||||||
|
Ok(record)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("创建视频生成任务失败: {}", e);
|
||||||
|
Err(e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取视频生成记录列表
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_volcano_video_generations(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
query: Option<VideoGenerationQuery>,
|
||||||
|
) -> Result<Vec<VideoGenerationRecord>, String> {
|
||||||
|
|
||||||
|
// 获取数据库连接
|
||||||
|
let database = {
|
||||||
|
let database_guard = state
|
||||||
|
.database
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| format!("获取数据库失败: {}", e))?;
|
||||||
|
database_guard
|
||||||
|
.as_ref()
|
||||||
|
.ok_or("数据库未初始化")?
|
||||||
|
.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建服务实例
|
||||||
|
let service = VolcanoVideoService::new(database);
|
||||||
|
|
||||||
|
// 使用默认查询参数或提供的参数
|
||||||
|
let query = query.unwrap_or_default();
|
||||||
|
|
||||||
|
// 获取记录列表
|
||||||
|
match service.get_video_generations(query).await {
|
||||||
|
Ok(records) => {
|
||||||
|
Ok(records)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("获取视频生成记录列表失败: {}", e);
|
||||||
|
Err(e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 根据ID获取视频生成记录
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_volcano_video_generation_by_id(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
id: String,
|
||||||
|
) -> Result<Option<VideoGenerationRecord>, String> {
|
||||||
|
info!("获取视频生成记录: {}", id);
|
||||||
|
|
||||||
|
// 获取数据库连接
|
||||||
|
let database = {
|
||||||
|
let database_guard = state
|
||||||
|
.database
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| format!("获取数据库失败: {}", e))?;
|
||||||
|
database_guard
|
||||||
|
.as_ref()
|
||||||
|
.ok_or("数据库未初始化")?
|
||||||
|
.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建服务实例
|
||||||
|
let service = VolcanoVideoService::new(database);
|
||||||
|
|
||||||
|
// 获取记录
|
||||||
|
match service.get_video_generation_by_id(&id).await {
|
||||||
|
Ok(record) => {
|
||||||
|
if record.is_some() {
|
||||||
|
info!("找到视频生成记录: {}", id);
|
||||||
|
} else {
|
||||||
|
info!("未找到视频生成记录: {}", id);
|
||||||
|
}
|
||||||
|
Ok(record)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("获取视频生成记录失败: {}", e);
|
||||||
|
Err(e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除视频生成记录
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_volcano_video_generation(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
id: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
info!("删除视频生成记录: {}", id);
|
||||||
|
|
||||||
|
// 获取数据库连接
|
||||||
|
let database = {
|
||||||
|
let database_guard = state
|
||||||
|
.database
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| format!("获取数据库失败: {}", e))?;
|
||||||
|
database_guard
|
||||||
|
.as_ref()
|
||||||
|
.ok_or("数据库未初始化")?
|
||||||
|
.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建服务实例
|
||||||
|
let service = VolcanoVideoService::new(database);
|
||||||
|
|
||||||
|
// 删除记录
|
||||||
|
match service.delete_video_generation(&id).await {
|
||||||
|
Ok(()) => {
|
||||||
|
info!("视频生成记录删除成功: {}", id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("删除视频生成记录失败: {}", e);
|
||||||
|
Err(e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 批量删除视频生成记录
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn batch_delete_volcano_video_generations(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
ids: Vec<String>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
info!("批量删除视频生成记录: {:?}", ids);
|
||||||
|
|
||||||
|
// 获取数据库连接
|
||||||
|
let database = {
|
||||||
|
let database_guard = state
|
||||||
|
.database
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| format!("获取数据库失败: {}", e))?;
|
||||||
|
database_guard
|
||||||
|
.as_ref()
|
||||||
|
.ok_or("数据库未初始化")?
|
||||||
|
.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建服务实例
|
||||||
|
let service = VolcanoVideoService::new(database);
|
||||||
|
|
||||||
|
// 批量删除记录
|
||||||
|
match service.batch_delete_video_generations(ids.clone()).await {
|
||||||
|
Ok(()) => {
|
||||||
|
info!("批量删除视频生成记录成功: {} 条", ids.len());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("批量删除视频生成记录失败: {}", e);
|
||||||
|
Err(e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 下载视频文件
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn download_volcano_video(
|
||||||
|
_state: State<'_, AppState>,
|
||||||
|
video_url: String,
|
||||||
|
save_path: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
info!("下载视频文件: {} -> {}", video_url, save_path);
|
||||||
|
|
||||||
|
// 使用reqwest下载文件
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
match client.get(&video_url).send().await {
|
||||||
|
Ok(response) => {
|
||||||
|
if response.status().is_success() {
|
||||||
|
match response.bytes().await {
|
||||||
|
Ok(bytes) => {
|
||||||
|
match tokio::fs::write(&save_path, bytes).await {
|
||||||
|
Ok(()) => {
|
||||||
|
info!("视频文件下载成功: {}", save_path);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("保存视频文件失败: {}", e);
|
||||||
|
Err(format!("保存视频文件失败: {}", e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("读取视频数据失败: {}", e);
|
||||||
|
Err(format!("读取视频数据失败: {}", e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let error_msg = format!("下载失败,HTTP状态码: {}", response.status());
|
||||||
|
error!("{}", error_msg);
|
||||||
|
Err(error_msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("下载视频文件失败: {}", e);
|
||||||
|
Err(format!("下载视频文件失败: {}", e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 下载视频到指定目录(带文件选择对话框)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn download_video_to_directory(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
video_url: String,
|
||||||
|
filename: String,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
use tauri_plugin_dialog::DialogExt;
|
||||||
|
|
||||||
|
info!("下载视频到指定目录: {} -> {}", video_url, filename);
|
||||||
|
|
||||||
|
// 获取文件扩展名
|
||||||
|
let extension = if filename.ends_with(".mp4") {
|
||||||
|
"mp4"
|
||||||
|
} else {
|
||||||
|
"mp4" // 默认使用mp4
|
||||||
|
};
|
||||||
|
|
||||||
|
// 显示保存对话框
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
|
||||||
|
app.dialog().file()
|
||||||
|
.set_title("保存视频文件")
|
||||||
|
.set_file_name(&filename)
|
||||||
|
.add_filter("视频文件", &[extension])
|
||||||
|
.add_filter("所有文件", &["*"])
|
||||||
|
.save_file(move |file_path| {
|
||||||
|
let _ = tx.send(file_path);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 等待用户选择
|
||||||
|
let file_path = match rx.recv_timeout(std::time::Duration::from_secs(30)) {
|
||||||
|
Ok(Some(path)) => path.to_string(),
|
||||||
|
Ok(None) => return Err("用户取消了保存操作".to_string()),
|
||||||
|
Err(_) => return Err("文件选择对话框超时".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建带有防盗链绕过的HTTP客户端
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("创建HTTP客户端失败: {}", e))?;
|
||||||
|
|
||||||
|
// 构建请求,添加必要的头部信息绕过防盗链
|
||||||
|
let request = client
|
||||||
|
.get(&video_url)
|
||||||
|
.header("Referer", "https://www.volcengine.com/")
|
||||||
|
.header("Origin", "https://www.volcengine.com")
|
||||||
|
.header("Accept", "video/webm,video/ogg,video/*;q=0.9,application/ogg;q=0.7,audio/*;q=0.6,*/*;q=0.5")
|
||||||
|
.header("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
|
||||||
|
.header("Cache-Control", "no-cache")
|
||||||
|
.header("Pragma", "no-cache")
|
||||||
|
.header("Sec-Fetch-Dest", "video")
|
||||||
|
.header("Sec-Fetch-Mode", "no-cors")
|
||||||
|
.header("Sec-Fetch-Site", "cross-site");
|
||||||
|
|
||||||
|
match request.send().await {
|
||||||
|
Ok(response) => {
|
||||||
|
if response.status().is_success() {
|
||||||
|
match response.bytes().await {
|
||||||
|
Ok(bytes) => {
|
||||||
|
// 确保目录存在
|
||||||
|
if let Some(parent) = std::path::Path::new(&file_path).parent() {
|
||||||
|
if let Err(e) = tokio::fs::create_dir_all(parent).await {
|
||||||
|
return Err(format!("创建目录失败: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match tokio::fs::write(&file_path, bytes).await {
|
||||||
|
Ok(()) => {
|
||||||
|
info!("视频文件下载成功: {}", file_path);
|
||||||
|
Ok(file_path)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("保存视频文件失败: {}", e);
|
||||||
|
Err(format!("保存视频文件失败: {}", e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("读取视频数据失败: {}", e);
|
||||||
|
Err(format!("读取视频数据失败: {}", e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let error_msg = format!("下载失败,HTTP状态码: {}", response.status());
|
||||||
|
error!("{}", error_msg);
|
||||||
|
Err(error_msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("下载视频文件失败: {}", e);
|
||||||
|
Err(format!("下载视频文件失败: {}", e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 批量下载视频文件
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn batch_download_volcano_videos(
|
||||||
|
_state: State<'_, AppState>,
|
||||||
|
downloads: Vec<(String, String)>, // (video_url, save_path) 对
|
||||||
|
) -> Result<Vec<String>, String> {
|
||||||
|
info!("批量下载视频文件: {} 个", downloads.len());
|
||||||
|
|
||||||
|
let mut results = Vec::new();
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
for (video_url, save_path) in downloads {
|
||||||
|
match client.get(&video_url).send().await {
|
||||||
|
Ok(response) => {
|
||||||
|
if response.status().is_success() {
|
||||||
|
match response.bytes().await {
|
||||||
|
Ok(bytes) => {
|
||||||
|
match tokio::fs::write(&save_path, bytes).await {
|
||||||
|
Ok(()) => {
|
||||||
|
info!("视频文件下载成功: {}", save_path);
|
||||||
|
results.push(format!("成功: {}", save_path));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let error_msg = format!("保存失败 {}: {}", save_path, e);
|
||||||
|
error!("{}", error_msg);
|
||||||
|
results.push(error_msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let error_msg = format!("读取数据失败 {}: {}", video_url, e);
|
||||||
|
error!("{}", error_msg);
|
||||||
|
results.push(error_msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let error_msg = format!("下载失败 {} (HTTP {})", video_url, response.status());
|
||||||
|
error!("{}", error_msg);
|
||||||
|
results.push(error_msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let error_msg = format!("请求失败 {}: {}", video_url, e);
|
||||||
|
error!("{}", error_msg);
|
||||||
|
results.push(error_msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取视频流的Base64数据,绕过防盗链限制
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_video_stream_base64(
|
||||||
|
video_url: String,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
info!("获取视频流Base64数据: {}", video_url);
|
||||||
|
|
||||||
|
// 创建带有防盗链绕过的HTTP客户端
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||||
|
.timeout(std::time::Duration::from_secs(60))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("创建HTTP客户端失败: {}", e))?;
|
||||||
|
|
||||||
|
// 构建请求,添加必要的头部信息
|
||||||
|
let request = client
|
||||||
|
.get(&video_url)
|
||||||
|
.header("Referer", "https://www.volcengine.com/")
|
||||||
|
.header("Origin", "https://www.volcengine.com")
|
||||||
|
.header("Accept", "video/webm,video/ogg,video/*;q=0.9,application/ogg;q=0.7,audio/*;q=0.6,*/*;q=0.5")
|
||||||
|
.header("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
|
||||||
|
.header("Cache-Control", "no-cache")
|
||||||
|
.header("Pragma", "no-cache")
|
||||||
|
.header("Sec-Fetch-Dest", "video")
|
||||||
|
.header("Sec-Fetch-Mode", "no-cors")
|
||||||
|
.header("Sec-Fetch-Site", "cross-site");
|
||||||
|
|
||||||
|
match request.send().await {
|
||||||
|
Ok(response) => {
|
||||||
|
if response.status().is_success() {
|
||||||
|
match response.bytes().await {
|
||||||
|
Ok(bytes) => {
|
||||||
|
use base64::{Engine as _, engine::general_purpose};
|
||||||
|
let base64_data = general_purpose::STANDARD.encode(&bytes);
|
||||||
|
let data_url = format!("data:video/mp4;base64,{}", base64_data);
|
||||||
|
info!("成功获取视频流Base64数据,大小: {} bytes", bytes.len());
|
||||||
|
Ok(data_url)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("读取视频流数据失败: {}", e);
|
||||||
|
Err(format!("读取视频流数据失败: {}", e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let error_msg = format!("获取视频流失败,HTTP状态码: {}", response.status());
|
||||||
|
error!("{}", error_msg);
|
||||||
|
Err(error_msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("获取视频流请求失败: {}", e);
|
||||||
|
Err(format!("获取视频流请求失败: {}", e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,8 @@ import OutfitComparisonTool from './pages/tools/OutfitComparisonTool';
|
|||||||
import MaterialSearchTool from './pages/tools/MaterialSearchTool';
|
import MaterialSearchTool from './pages/tools/MaterialSearchTool';
|
||||||
import ImageGenerationTool from './pages/tools/ImageGenerationTool';
|
import ImageGenerationTool from './pages/tools/ImageGenerationTool';
|
||||||
import VoiceGenerationHistory from './pages/tools/VoiceGenerationHistory';
|
import VoiceGenerationHistory from './pages/tools/VoiceGenerationHistory';
|
||||||
|
import VoiceCloneTool from './pages/tools/VoiceCloneTool';
|
||||||
|
import VideoGenerationTool from './pages/tools/VideoGenerationTool';
|
||||||
import { EnrichedAnalysisDemo } from './pages/tools/EnrichedAnalysisDemo';
|
import { EnrichedAnalysisDemo } from './pages/tools/EnrichedAnalysisDemo';
|
||||||
import MaterialCenter from './pages/MaterialCenter';
|
import MaterialCenter from './pages/MaterialCenter';
|
||||||
import VideoGeneration from './pages/VideoGeneration';
|
import VideoGeneration from './pages/VideoGeneration';
|
||||||
@@ -141,7 +143,9 @@ function App() {
|
|||||||
<Route path="/tools/outfit-comparison" element={<OutfitComparisonTool />} />
|
<Route path="/tools/outfit-comparison" element={<OutfitComparisonTool />} />
|
||||||
<Route path="/tools/material-search" element={<MaterialSearchTool />} />
|
<Route path="/tools/material-search" element={<MaterialSearchTool />} />
|
||||||
<Route path="/tools/image-generation" element={<ImageGenerationTool />} />
|
<Route path="/tools/image-generation" element={<ImageGenerationTool />} />
|
||||||
<Route path="/tools/voice-clone" element={<VoiceGenerationHistory />} />
|
<Route path="/tools/voice-generation-history" element={<VoiceGenerationHistory />} />
|
||||||
|
<Route path="/tools/voice-clone" element={<VoiceCloneTool />} />
|
||||||
|
<Route path="/tools/volcano-video-generation" element={<VideoGenerationTool />} />
|
||||||
<Route path="/tools/advanced-filter-demo" element={<AdvancedFilterTool />} />
|
<Route path="/tools/advanced-filter-demo" element={<AdvancedFilterTool />} />
|
||||||
<Route path="/tools/enriched-analysis-demo" element={<EnrichedAnalysisDemo />} />
|
<Route path="/tools/enriched-analysis-demo" element={<EnrichedAnalysisDemo />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
408
apps/desktop/src/components/VideoPreviewModal.tsx
Normal file
408
apps/desktop/src/components/VideoPreviewModal.tsx
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
|
import { Modal } from './Modal';
|
||||||
|
import {
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
Volume2,
|
||||||
|
VolumeX,
|
||||||
|
Download,
|
||||||
|
ExternalLink,
|
||||||
|
Maximize,
|
||||||
|
SkipBack,
|
||||||
|
SkipForward
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { useNotifications } from './NotificationSystem';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视频预览模态框属性接口
|
||||||
|
*/
|
||||||
|
interface VideoPreviewModalProps {
|
||||||
|
/** 是否显示模态框 */
|
||||||
|
isOpen: boolean;
|
||||||
|
/** 视频URL */
|
||||||
|
videoUrl: string | null;
|
||||||
|
/** 视频标题 */
|
||||||
|
title?: string;
|
||||||
|
/** 关闭回调 */
|
||||||
|
onClose: () => void;
|
||||||
|
/** 下载回调 */
|
||||||
|
onDownload?: (videoUrl: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视频预览模态框组件
|
||||||
|
* 支持视频播放控制、全屏、下载等功能
|
||||||
|
*/
|
||||||
|
export const VideoPreviewModal: React.FC<VideoPreviewModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
videoUrl,
|
||||||
|
title = '视频预览',
|
||||||
|
onClose,
|
||||||
|
onDownload
|
||||||
|
}) => {
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [isMuted, setIsMuted] = useState(false);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
|
const [proxiedVideoUrl, setProxiedVideoUrl] = useState<string | null>(null);
|
||||||
|
const [isLoadingProxy, setIsLoadingProxy] = useState(false);
|
||||||
|
|
||||||
|
const { addNotification } = useNotifications();
|
||||||
|
|
||||||
|
// 加载代理视频
|
||||||
|
useEffect(() => {
|
||||||
|
if (!videoUrl || !isOpen) return;
|
||||||
|
|
||||||
|
// 重置状态
|
||||||
|
setError(null);
|
||||||
|
setIsLoading(true);
|
||||||
|
setIsLoadingProxy(false);
|
||||||
|
|
||||||
|
// 首先尝试直接加载
|
||||||
|
setProxiedVideoUrl(videoUrl);
|
||||||
|
}, [videoUrl, isOpen]);
|
||||||
|
|
||||||
|
// 重置状态当视频URL改变时
|
||||||
|
useEffect(() => {
|
||||||
|
if (videoUrl) {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setIsPlaying(false);
|
||||||
|
setCurrentTime(0);
|
||||||
|
setDuration(0);
|
||||||
|
}
|
||||||
|
}, [videoUrl]);
|
||||||
|
|
||||||
|
// 播放/暂停控制
|
||||||
|
const handlePlayPause = useCallback(() => {
|
||||||
|
if (!videoRef.current) return;
|
||||||
|
|
||||||
|
if (isPlaying) {
|
||||||
|
videoRef.current.pause();
|
||||||
|
} else {
|
||||||
|
videoRef.current.play().catch(err => {
|
||||||
|
console.error('播放失败:', err);
|
||||||
|
setError('视频播放失败');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isPlaying]);
|
||||||
|
|
||||||
|
// 静音控制
|
||||||
|
const handleMuteToggle = useCallback(() => {
|
||||||
|
if (!videoRef.current) return;
|
||||||
|
|
||||||
|
const newMuted = !isMuted;
|
||||||
|
videoRef.current.muted = newMuted;
|
||||||
|
setIsMuted(newMuted);
|
||||||
|
}, [isMuted]);
|
||||||
|
|
||||||
|
// 进度控制
|
||||||
|
const handleSeek = useCallback((newTime: number) => {
|
||||||
|
if (!videoRef.current) return;
|
||||||
|
|
||||||
|
videoRef.current.currentTime = newTime;
|
||||||
|
setCurrentTime(newTime);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 快进/快退
|
||||||
|
const handleSkip = useCallback((seconds: number) => {
|
||||||
|
if (!videoRef.current) return;
|
||||||
|
|
||||||
|
const newTime = Math.max(0, Math.min(duration, currentTime + seconds));
|
||||||
|
handleSeek(newTime);
|
||||||
|
}, [currentTime, duration, handleSeek]);
|
||||||
|
|
||||||
|
// 全屏控制
|
||||||
|
const handleFullscreen = useCallback(() => {
|
||||||
|
if (!videoRef.current) return;
|
||||||
|
|
||||||
|
if (!isFullscreen) {
|
||||||
|
if (videoRef.current.requestFullscreen) {
|
||||||
|
videoRef.current.requestFullscreen();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (document.exitFullscreen) {
|
||||||
|
document.exitFullscreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isFullscreen]);
|
||||||
|
|
||||||
|
// 下载视频
|
||||||
|
const handleDownload = useCallback(async () => {
|
||||||
|
if (!videoUrl) return;
|
||||||
|
|
||||||
|
setIsDownloading(true);
|
||||||
|
try {
|
||||||
|
// 调用Tauri命令下载视频
|
||||||
|
await invoke('download_video_to_directory', {
|
||||||
|
videoUrl,
|
||||||
|
filename: `video_${Date.now()}.mp4`
|
||||||
|
});
|
||||||
|
|
||||||
|
addNotification({
|
||||||
|
type: 'success',
|
||||||
|
title: '下载成功',
|
||||||
|
message: '视频下载成功'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (onDownload) {
|
||||||
|
onDownload(videoUrl);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('下载失败:', error);
|
||||||
|
addNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: '下载失败',
|
||||||
|
message: `视频下载失败: ${error}`
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsDownloading(false);
|
||||||
|
}
|
||||||
|
}, [videoUrl, onDownload, addNotification]);
|
||||||
|
|
||||||
|
// 在新窗口打开
|
||||||
|
const handleOpenInNewWindow = useCallback(() => {
|
||||||
|
if (videoUrl) {
|
||||||
|
window.open(videoUrl, '_blank');
|
||||||
|
}
|
||||||
|
}, [videoUrl]);
|
||||||
|
|
||||||
|
// 视频事件处理
|
||||||
|
const handleVideoLoad = useCallback(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
if (videoRef.current) {
|
||||||
|
setDuration(videoRef.current.duration);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleVideoError = useCallback(async () => {
|
||||||
|
console.log('视频加载失败,尝试使用代理服务');
|
||||||
|
|
||||||
|
// 如果还没有尝试过代理,则尝试使用代理服务
|
||||||
|
if (proxiedVideoUrl === videoUrl) {
|
||||||
|
try {
|
||||||
|
setIsLoading(false); // 停止普通加载状态
|
||||||
|
setIsLoadingProxy(true); // 开始代理加载状态
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
console.log('开始获取代理视频数据...');
|
||||||
|
const proxiedData = await invoke<string>('get_video_stream_base64', {
|
||||||
|
videoUrl: videoUrl
|
||||||
|
});
|
||||||
|
|
||||||
|
setProxiedVideoUrl(proxiedData);
|
||||||
|
console.log('成功获取代理视频数据,大小:', proxiedData.length);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('代理视频加载失败:', error);
|
||||||
|
setError('视频加载失败,请检查网络连接或视频链接');
|
||||||
|
} finally {
|
||||||
|
setIsLoadingProxy(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setIsLoading(false);
|
||||||
|
setError('视频加载失败,请检查网络连接或视频链接');
|
||||||
|
}
|
||||||
|
}, [videoUrl, proxiedVideoUrl]);
|
||||||
|
|
||||||
|
const handleTimeUpdate = useCallback(() => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
setCurrentTime(videoRef.current.currentTime);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePlay = useCallback(() => {
|
||||||
|
setIsPlaying(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePause = useCallback(() => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFullscreenChange = useCallback(() => {
|
||||||
|
setIsFullscreen(!!document.fullscreenElement);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 监听全屏变化
|
||||||
|
useEffect(() => {
|
||||||
|
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||||
|
};
|
||||||
|
}, [handleFullscreenChange]);
|
||||||
|
|
||||||
|
// 格式化时间
|
||||||
|
const formatTime = (time: number) => {
|
||||||
|
const minutes = Math.floor(time / 60);
|
||||||
|
const seconds = Math.floor(time % 60);
|
||||||
|
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen || !videoUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title={title}
|
||||||
|
size="xl"
|
||||||
|
className="video-preview-modal"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 视频播放区域 */}
|
||||||
|
<div className="relative bg-black rounded-lg overflow-hidden aspect-video group">
|
||||||
|
{(isLoading || isLoadingProxy) && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-gray-900">
|
||||||
|
<div className="flex flex-col items-center space-y-3">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
|
||||||
|
<p className="text-white text-sm">
|
||||||
|
{isLoadingProxy ? '正在获取视频流...' : '加载中...'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 text-white">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-red-400 mb-2">⚠️</div>
|
||||||
|
<p>{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
className="w-full h-full object-contain"
|
||||||
|
onLoadedData={handleVideoLoad}
|
||||||
|
onError={handleVideoError}
|
||||||
|
onTimeUpdate={handleTimeUpdate}
|
||||||
|
onPlay={handlePlay}
|
||||||
|
onPause={handlePause}
|
||||||
|
controls={false}
|
||||||
|
crossOrigin="anonymous"
|
||||||
|
>
|
||||||
|
{proxiedVideoUrl && (
|
||||||
|
<source src={proxiedVideoUrl} type="video/mp4" />
|
||||||
|
)}
|
||||||
|
您的浏览器不支持视频播放。
|
||||||
|
</video>
|
||||||
|
|
||||||
|
{/* 右上角悬浮按钮组 */}
|
||||||
|
{!isLoading && !isLoadingProxy && !error && (
|
||||||
|
<div className="absolute top-4 right-4 flex items-center space-x-2 opacity-80 hover:opacity-100 group-hover:opacity-100 transition-all duration-300 z-10">
|
||||||
|
<button
|
||||||
|
onClick={handleDownload}
|
||||||
|
disabled={isDownloading}
|
||||||
|
className="relative flex items-center justify-center w-10 h-10 bg-black/60 hover:bg-green-600/80 disabled:bg-gray-600/60 text-white rounded-full backdrop-blur-sm transition-all duration-200 hover:scale-110 disabled:cursor-not-allowed shadow-lg"
|
||||||
|
title={isDownloading ? '下载中...' : '下载视频'}
|
||||||
|
>
|
||||||
|
{isDownloading ? (
|
||||||
|
<div className="animate-spin rounded-full h-5 w-5 border-2 border-white border-t-transparent"></div>
|
||||||
|
) : (
|
||||||
|
<Download className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleOpenInNewWindow}
|
||||||
|
className="flex items-center justify-center w-10 h-10 bg-black/60 hover:bg-blue-600/80 text-white rounded-full backdrop-blur-sm transition-all duration-200 hover:scale-110 shadow-lg"
|
||||||
|
title="在新窗口打开"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleFullscreen}
|
||||||
|
className="flex items-center justify-center w-10 h-10 bg-black/60 hover:bg-purple-600/80 text-white rounded-full backdrop-blur-sm transition-all duration-200 hover:scale-110 shadow-lg"
|
||||||
|
title="全屏播放"
|
||||||
|
>
|
||||||
|
<Maximize className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 视频控制覆盖层 */}
|
||||||
|
{!isLoading && !error && (
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/50 via-transparent to-transparent opacity-0 hover:opacity-100 transition-opacity duration-300">
|
||||||
|
{/* 中央播放按钮 */}
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<button
|
||||||
|
onClick={handlePlayPause}
|
||||||
|
className="bg-white/20 hover:bg-white/30 rounded-full p-4 transition-colors duration-200"
|
||||||
|
>
|
||||||
|
{isPlaying ? (
|
||||||
|
<Pause className="w-8 h-8 text-white" />
|
||||||
|
) : (
|
||||||
|
<Play className="w-8 h-8 text-white ml-1" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部控制栏 */}
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 p-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* 进度条 */}
|
||||||
|
<div className="flex items-center space-x-2 text-white text-sm">
|
||||||
|
<span>{formatTime(currentTime)}</span>
|
||||||
|
<div className="flex-1 bg-white/20 rounded-full h-1">
|
||||||
|
<div
|
||||||
|
className="bg-blue-500 h-1 rounded-full transition-all duration-100"
|
||||||
|
style={{ width: `${duration > 0 ? (currentTime / duration) * 100 : 0}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span>{formatTime(duration)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 控制按钮 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleSkip(-10)}
|
||||||
|
className="text-white hover:text-blue-400 transition-colors duration-200"
|
||||||
|
title="后退10秒"
|
||||||
|
>
|
||||||
|
<SkipBack className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handlePlayPause}
|
||||||
|
className="text-white hover:text-blue-400 transition-colors duration-200"
|
||||||
|
>
|
||||||
|
{isPlaying ? <Pause className="w-5 h-5" /> : <Play className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleSkip(10)}
|
||||||
|
className="text-white hover:text-blue-400 transition-colors duration-200"
|
||||||
|
title="前进10秒"
|
||||||
|
>
|
||||||
|
<SkipForward className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleMuteToggle}
|
||||||
|
className="text-white hover:text-blue-400 transition-colors duration-200"
|
||||||
|
>
|
||||||
|
{isMuted ? <VolumeX className="w-5 h-5" /> : <Volume2 className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VideoPreviewModal;
|
||||||
@@ -8,7 +8,8 @@ import {
|
|||||||
Heart,
|
Heart,
|
||||||
ArrowLeftRight,
|
ArrowLeftRight,
|
||||||
Image,
|
Image,
|
||||||
Mic
|
Mic,
|
||||||
|
Video
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Tool, ToolCategory, ToolStatus } from '../types/tool';
|
import { Tool, ToolCategory, ToolStatus } from '../types/tool';
|
||||||
|
|
||||||
@@ -136,6 +137,21 @@ export const TOOLS_DATA: Tool[] = [
|
|||||||
isPopular: true,
|
isPopular: true,
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
lastUpdated: '2024-01-27'
|
lastUpdated: '2024-01-27'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'volcano-video-generation',
|
||||||
|
name: '火山云视频生成',
|
||||||
|
description: '基于火山云API的智能视频生成工具,支持图片转视频、音频配音等功能',
|
||||||
|
longDescription: '专业的火山云视频生成工具,集成火山云先进的视频生成API。支持图片转视频、音频配音、多种视频参数配置、实时进度监控等功能。提供直观的任务管理界面,支持批量操作、下载管理等完整的视频生成流程。适用于内容创作、营销推广、艺术创作等多种场景。',
|
||||||
|
icon: Video,
|
||||||
|
route: '/tools/volcano-video-generation',
|
||||||
|
category: ToolCategory.AI_TOOLS,
|
||||||
|
status: ToolStatus.STABLE,
|
||||||
|
tags: ['视频生成', '图片转视频', '音频配音', '火山云API', '批量处理'],
|
||||||
|
isNew: true,
|
||||||
|
isPopular: true,
|
||||||
|
version: '1.0.0',
|
||||||
|
lastUpdated: '2024-01-31'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
688
apps/desktop/src/pages/tools/VideoGenerationTool.tsx
Normal file
688
apps/desktop/src/pages/tools/VideoGenerationTool.tsx
Normal file
@@ -0,0 +1,688 @@
|
|||||||
|
import React, { useState, useCallback, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Video,
|
||||||
|
Download,
|
||||||
|
Trash2,
|
||||||
|
RefreshCw,
|
||||||
|
Plus,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
Clock,
|
||||||
|
Loader2,
|
||||||
|
Image as ImageIcon,
|
||||||
|
Eye,
|
||||||
|
X,
|
||||||
|
FileVideo
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { open } from '@tauri-apps/plugin-dialog';
|
||||||
|
import { useNotifications } from '../../components/NotificationSystem';
|
||||||
|
import VideoPreviewModal from '../../components/VideoPreviewModal';
|
||||||
|
|
||||||
|
// 火山云视频生成相关类型定义
|
||||||
|
interface VideoGenerationRecord {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
image_url?: string;
|
||||||
|
audio_url?: string;
|
||||||
|
prompt?: string;
|
||||||
|
negative_prompt?: string;
|
||||||
|
duration: number;
|
||||||
|
fps: number;
|
||||||
|
resolution: string;
|
||||||
|
style?: string;
|
||||||
|
motion_strength: number;
|
||||||
|
status: 'Pending' | 'Processing' | 'Completed' | 'Failed';
|
||||||
|
progress: number;
|
||||||
|
result_video_url?: string;
|
||||||
|
result_thumbnail_url?: string;
|
||||||
|
error_message?: string;
|
||||||
|
vol_task_id?: string;
|
||||||
|
project_id?: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateVideoGenerationRequest {
|
||||||
|
name: string;
|
||||||
|
image_url?: string;
|
||||||
|
audio_url?: string; // 实际上是驱动视频URL,复用audio_url字段
|
||||||
|
// 注意:根据火山云API文档,需要图片和驱动视频两个文件
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 火山云视频生成工具
|
||||||
|
* 遵循 Tauri 开发规范和 UI/UX 设计标准
|
||||||
|
*/
|
||||||
|
const VideoGenerationTool: React.FC = () => {
|
||||||
|
const { addNotification } = useNotifications();
|
||||||
|
|
||||||
|
// ============= 状态管理 =============
|
||||||
|
|
||||||
|
// 视频生成记录列表
|
||||||
|
const [records, setRecords] = useState<VideoGenerationRecord[]>([]);
|
||||||
|
const [isLoadingRecords, setIsLoadingRecords] = useState(false);
|
||||||
|
const [selectedRecords, setSelectedRecords] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// 创建任务表单
|
||||||
|
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const [formData, setFormData] = useState<CreateVideoGenerationRequest>({
|
||||||
|
name: '',
|
||||||
|
image_url: '',
|
||||||
|
audio_url: '', // 驱动视频URL
|
||||||
|
});
|
||||||
|
|
||||||
|
// 视频预览相关状态
|
||||||
|
const [previewVideoUrl, setPreviewVideoUrl] = useState<string | null>(null);
|
||||||
|
const [previewVideoTitle, setPreviewVideoTitle] = useState<string>('');
|
||||||
|
|
||||||
|
// ============= 数据加载 =============
|
||||||
|
|
||||||
|
// 加载视频生成记录
|
||||||
|
const loadRecords = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setIsLoadingRecords(true);
|
||||||
|
const result = await invoke<VideoGenerationRecord[]>('get_volcano_video_generations', {
|
||||||
|
query: null
|
||||||
|
});
|
||||||
|
setRecords(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载视频生成记录失败:', error);
|
||||||
|
addNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: '加载失败',
|
||||||
|
message: '加载视频生成记录失败'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoadingRecords(false);
|
||||||
|
}
|
||||||
|
}, [addNotification]);
|
||||||
|
|
||||||
|
// 组件挂载时加载数据
|
||||||
|
useEffect(() => {
|
||||||
|
loadRecords();
|
||||||
|
}, [loadRecords]);
|
||||||
|
|
||||||
|
// ============= 文件选择 =============
|
||||||
|
|
||||||
|
// 选择图片文件
|
||||||
|
const selectImageFile = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const selected = await open({
|
||||||
|
multiple: false,
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
name: '图片文件',
|
||||||
|
extensions: ['jpg', 'jpeg', 'png', 'webp', 'bmp'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (selected && typeof selected === 'string') {
|
||||||
|
setFormData(prev => ({ ...prev, image_url: selected }));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('选择图片文件失败:', error);
|
||||||
|
addNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: '文件选择失败',
|
||||||
|
message: '选择图片文件失败'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [addNotification]);
|
||||||
|
|
||||||
|
// 选择驱动视频文件
|
||||||
|
const selectAudioFile = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const selected = await open({
|
||||||
|
multiple: false,
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
name: '视频文件',
|
||||||
|
extensions: ['mp4', 'avi', 'mov', 'mkv', 'webm'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (selected && typeof selected === 'string') {
|
||||||
|
setFormData(prev => ({ ...prev, audio_url: selected }));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('选择驱动视频文件失败:', error);
|
||||||
|
addNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: '文件选择失败',
|
||||||
|
message: '选择驱动视频文件失败'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [addNotification]);
|
||||||
|
|
||||||
|
// ============= 任务操作 =============
|
||||||
|
|
||||||
|
// 创建视频生成任务
|
||||||
|
const createVideoGeneration = useCallback(async () => {
|
||||||
|
if (!formData.name.trim()) {
|
||||||
|
addNotification({
|
||||||
|
type: 'warning',
|
||||||
|
title: '输入错误',
|
||||||
|
message: '请输入任务名称'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.image_url) {
|
||||||
|
addNotification({
|
||||||
|
type: 'warning',
|
||||||
|
title: '输入错误',
|
||||||
|
message: '请选择图片文件'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.audio_url) {
|
||||||
|
addNotification({
|
||||||
|
type: 'warning',
|
||||||
|
title: '输入错误',
|
||||||
|
message: '请选择驱动视频文件'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsCreating(true);
|
||||||
|
await invoke('create_volcano_video_generation', { request: formData });
|
||||||
|
addNotification({
|
||||||
|
type: 'success',
|
||||||
|
title: '创建成功',
|
||||||
|
message: '视频生成任务创建成功'
|
||||||
|
});
|
||||||
|
setShowCreateForm(false);
|
||||||
|
setFormData({
|
||||||
|
name: '',
|
||||||
|
image_url: '',
|
||||||
|
audio_url: '',
|
||||||
|
});
|
||||||
|
await loadRecords();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('创建视频生成任务失败:', error);
|
||||||
|
addNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: '创建失败',
|
||||||
|
message: '创建视频生成任务失败'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false);
|
||||||
|
}
|
||||||
|
}, [formData, addNotification, loadRecords]);
|
||||||
|
|
||||||
|
// 删除视频生成记录
|
||||||
|
const deleteRecord = useCallback(async (id: string) => {
|
||||||
|
try {
|
||||||
|
await invoke('delete_volcano_video_generation', { id });
|
||||||
|
addNotification({
|
||||||
|
type: 'success',
|
||||||
|
title: '删除成功',
|
||||||
|
message: '视频生成记录已删除'
|
||||||
|
});
|
||||||
|
await loadRecords();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除视频生成记录失败:', error);
|
||||||
|
addNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: '删除失败',
|
||||||
|
message: '删除视频生成记录失败'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [addNotification, loadRecords]);
|
||||||
|
|
||||||
|
// ============= 视频预览相关 =============
|
||||||
|
|
||||||
|
// 打开视频预览
|
||||||
|
const handlePreviewVideo = useCallback((record: VideoGenerationRecord) => {
|
||||||
|
if (record.result_video_url) {
|
||||||
|
setPreviewVideoUrl(record.result_video_url);
|
||||||
|
setPreviewVideoTitle(record.name || '视频预览');
|
||||||
|
} else {
|
||||||
|
addNotification({
|
||||||
|
type: 'warning',
|
||||||
|
title: '预览失败',
|
||||||
|
message: '该视频还未生成完成'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [addNotification]);
|
||||||
|
|
||||||
|
// 关闭视频预览
|
||||||
|
const handleClosePreview = useCallback(() => {
|
||||||
|
setPreviewVideoUrl(null);
|
||||||
|
setPreviewVideoTitle('');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 下载视频
|
||||||
|
const handleDownloadVideo = useCallback(async (videoUrl: string) => {
|
||||||
|
try {
|
||||||
|
const filename = `volcano_video_${Date.now()}.mp4`;
|
||||||
|
const savedPath = await invoke('download_video_to_directory', {
|
||||||
|
videoUrl,
|
||||||
|
filename
|
||||||
|
});
|
||||||
|
|
||||||
|
addNotification({
|
||||||
|
type: 'success',
|
||||||
|
title: '下载成功',
|
||||||
|
message: `视频已保存到: ${savedPath}`
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('下载视频失败:', error);
|
||||||
|
addNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: '下载失败',
|
||||||
|
message: `下载视频失败: ${error}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [addNotification]);
|
||||||
|
|
||||||
|
// 批量删除选中记录
|
||||||
|
const batchDeleteRecords = useCallback(async () => {
|
||||||
|
if (selectedRecords.length === 0) {
|
||||||
|
addNotification({
|
||||||
|
type: 'warning',
|
||||||
|
title: '选择错误',
|
||||||
|
message: '请选择要删除的记录'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await invoke('batch_delete_volcano_video_generations', { ids: selectedRecords });
|
||||||
|
addNotification({
|
||||||
|
type: 'success',
|
||||||
|
title: '删除成功',
|
||||||
|
message: `已删除 ${selectedRecords.length} 条记录`
|
||||||
|
});
|
||||||
|
setSelectedRecords([]);
|
||||||
|
await loadRecords();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('批量删除失败:', error);
|
||||||
|
addNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: '删除失败',
|
||||||
|
message: '批量删除记录失败'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [selectedRecords, addNotification, loadRecords]);
|
||||||
|
|
||||||
|
// ============= 渲染函数 =============
|
||||||
|
|
||||||
|
// 渲染状态标签
|
||||||
|
const renderStatusBadge = (status: string) => {
|
||||||
|
const statusConfig = {
|
||||||
|
'Pending': { color: 'bg-yellow-100 text-yellow-800', icon: Clock, label: '等待中' },
|
||||||
|
'Processing': { color: 'bg-blue-100 text-blue-800', icon: Loader2, label: '处理中' },
|
||||||
|
'Completed': { color: 'bg-green-100 text-green-800', icon: CheckCircle, label: '已完成' },
|
||||||
|
'Failed': { color: 'bg-red-100 text-red-800', icon: XCircle, label: '失败' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = statusConfig[status as keyof typeof statusConfig] || statusConfig['Pending'];
|
||||||
|
const Icon = config.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${config.color}`}>
|
||||||
|
<Icon className="w-3 h-3 mr-1" />
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-purple-50">
|
||||||
|
<div className="container mx-auto px-6 py-8">
|
||||||
|
{/* 页面标题 */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex items-center space-x-3 mb-4">
|
||||||
|
<div className="p-3 bg-gradient-to-r from-blue-500 to-purple-600 rounded-xl shadow-lg">
|
||||||
|
<Video className="w-8 h-8 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">火山云视频生成</h1>
|
||||||
|
<p className="text-gray-600 mt-1">基于火山云API的智能视频生成工具</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 工具栏 */}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4 mb-6">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateForm(true)}
|
||||||
|
className="inline-flex items-center px-4 py-2 bg-gradient-to-r from-blue-500 to-blue-600 text-white rounded-lg hover:from-blue-600 hover:to-blue-700 transition-all duration-200 shadow-md hover:shadow-lg"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
创建任务
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={loadRecords}
|
||||||
|
disabled={isLoadingRecords}
|
||||||
|
className="inline-flex items-center px-4 py-2 bg-white border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors duration-200 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 mr-2 ${isLoadingRecords ? 'animate-spin' : ''}`} />
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{selectedRecords.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={batchDeleteRecords}
|
||||||
|
className="inline-flex items-center px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
删除选中 ({selectedRecords.length})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
共 {records.length} 条记录
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 记录列表 */}
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||||
|
{isLoadingRecords ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-blue-500" />
|
||||||
|
<span className="ml-3 text-gray-600">加载中...</span>
|
||||||
|
</div>
|
||||||
|
) : records.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Video className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-2">暂无视频生成记录</h3>
|
||||||
|
<p className="text-gray-500 mb-6">点击"创建任务"开始生成您的第一个视频</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateForm(true)}
|
||||||
|
className="inline-flex items-center px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
创建任务
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedRecords.length === records.length && records.length > 0}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
setSelectedRecords(records.map(r => r.id));
|
||||||
|
} else {
|
||||||
|
setSelectedRecords([]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
任务信息
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
状态
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
创建时间
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
操作
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{records.map((record) => (
|
||||||
|
<tr key={record.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedRecords.includes(record.id)}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
setSelectedRecords([...selectedRecords, record.id]);
|
||||||
|
} else {
|
||||||
|
setSelectedRecords(selectedRecords.filter(id => id !== record.id));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
{record.result_thumbnail_url ? (
|
||||||
|
<img
|
||||||
|
src={record.result_thumbnail_url}
|
||||||
|
alt={record.name}
|
||||||
|
className="w-12 h-12 rounded-lg object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center">
|
||||||
|
<FileVideo className="w-6 h-6 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-gray-900 truncate">
|
||||||
|
{record.name}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center space-x-4 mt-1">
|
||||||
|
{record.image_url && (
|
||||||
|
<span className="inline-flex items-center text-xs text-gray-500">
|
||||||
|
<ImageIcon className="w-3 h-3 mr-1" />
|
||||||
|
图片
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{record.audio_url && (
|
||||||
|
<span className="inline-flex items-center text-xs text-gray-500">
|
||||||
|
<FileVideo className="w-3 h-3 mr-1" />
|
||||||
|
驱动视频
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
{renderStatusBadge(record.status)}
|
||||||
|
{record.status === 'Processing' && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${record.progress}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-500 mt-1">{record.progress}%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{record.error_message && (
|
||||||
|
<p className="text-xs text-red-500 mt-1 truncate" title={record.error_message}>
|
||||||
|
{record.error_message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{new Date(record.created_at).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
{record.result_video_url && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => handlePreviewVideo(record)}
|
||||||
|
className="text-blue-600 hover:text-blue-900 transition-colors duration-200"
|
||||||
|
title="预览视频"
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDownloadVideo(record.result_video_url!)}
|
||||||
|
className="text-green-600 hover:text-green-900 transition-colors duration-200"
|
||||||
|
title="下载视频"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => deleteRecord(record.id)}
|
||||||
|
className="text-red-600 hover:text-red-900 transition-colors duration-200"
|
||||||
|
title="删除记录"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 创建任务表单对话框 */}
|
||||||
|
{showCreateForm && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-xl shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900">创建视频生成任务</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateForm(false)}
|
||||||
|
className="text-gray-400 hover:text-gray-600 transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<X className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 space-y-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">
|
||||||
|
任务名称 *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
placeholder="请输入任务名称"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 文件上传 */}
|
||||||
|
<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">
|
||||||
|
图片文件 *
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<button
|
||||||
|
onClick={selectImageFile}
|
||||||
|
className="inline-flex items-center px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<ImageIcon className="w-4 h-4 mr-2" />
|
||||||
|
选择图片
|
||||||
|
</button>
|
||||||
|
{formData.image_url && (
|
||||||
|
<span className="text-sm text-gray-600 truncate">
|
||||||
|
{formData.image_url.split('/').pop() || formData.image_url}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
驱动视频文件(必需)
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<button
|
||||||
|
onClick={selectAudioFile}
|
||||||
|
className="inline-flex items-center px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<Video className="w-4 h-4 mr-2" />
|
||||||
|
选择驱动视频
|
||||||
|
</button>
|
||||||
|
{formData.audio_url && (
|
||||||
|
<span className="text-sm text-gray-600 truncate">
|
||||||
|
{formData.audio_url.split('/').pop() || formData.audio_url}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* API说明 */}
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||||
|
<h3 className="text-lg font-medium text-blue-900 mb-2">使用说明</h3>
|
||||||
|
<div className="text-sm text-blue-800 space-y-1">
|
||||||
|
<p>• 此功能使用火山云单图视频驱动API</p>
|
||||||
|
<p>• 需要提供一张图片和一个驱动视频</p>
|
||||||
|
<p>• 系统将根据驱动视频的动作来驱动图片中的人物</p>
|
||||||
|
<p>• 生成的视频时长和分辨率由驱动视频决定</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end space-x-3 p-6 border-t border-gray-200">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateForm(false)}
|
||||||
|
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors duration-200"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={createVideoGeneration}
|
||||||
|
disabled={isCreating}
|
||||||
|
className="inline-flex items-center px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors duration-200 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isCreating ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
创建中...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
创建任务
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 视频预览模态框 */}
|
||||||
|
<VideoPreviewModal
|
||||||
|
isOpen={!!previewVideoUrl}
|
||||||
|
videoUrl={previewVideoUrl}
|
||||||
|
title={previewVideoTitle}
|
||||||
|
onClose={handleClosePreview}
|
||||||
|
onDownload={handleDownloadVideo}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VideoGenerationTool;
|
||||||
@@ -18,8 +18,6 @@ export class OutfitImageService {
|
|||||||
*/
|
*/
|
||||||
static async getModelDashboardStats(modelId: string): Promise<ModelDashboardStats> {
|
static async getModelDashboardStats(modelId: string): Promise<ModelDashboardStats> {
|
||||||
try {
|
try {
|
||||||
console.log('📊 获取模特个人看板统计信息:', modelId);
|
|
||||||
|
|
||||||
const stats = await invoke<ModelDashboardStats>('get_model_dashboard_stats', {
|
const stats = await invoke<ModelDashboardStats>('get_model_dashboard_stats', {
|
||||||
modelId
|
modelId
|
||||||
});
|
});
|
||||||
@@ -59,7 +57,6 @@ export class OutfitImageService {
|
|||||||
pageSize: number = 20
|
pageSize: number = 20
|
||||||
): Promise<OutfitImageRecordsResponse> {
|
): Promise<OutfitImageRecordsResponse> {
|
||||||
try {
|
try {
|
||||||
console.log('📋 分页获取模特穿搭图片生成记录:', modelId, `页码: ${page}, 每页: ${pageSize}`);
|
|
||||||
|
|
||||||
const response = await invoke<OutfitImageRecordsResponse>('get_outfit_image_records_paginated', {
|
const response = await invoke<OutfitImageRecordsResponse>('get_outfit_image_records_paginated', {
|
||||||
modelId,
|
modelId,
|
||||||
|
|||||||
Reference in New Issue
Block a user