merge: 合并video-generation-feature分支到master

合并内容:
- 视频生成功能完整实现
- 火山云API集成和自动下载上传
- 视频预览组件和下载功能
- ComfyUI JSON替换优化
- 换装图片生成功能改进

解决冲突:
- App.tsx路由配置合并
- 保留语音生成历史和语音克隆功能
- 新增火山云视频生成工具路由

新增功能:
- 视频生成任务管理
- 视频预览和下载
- 自动CDN上传
- 防盗链处理
- 批量操作支持
This commit is contained in:
imeepos
2025-07-31 14:34:59 +08:00
31 changed files with 2995 additions and 76 deletions

20
Cargo.lock generated
View File

@@ -799,6 +799,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
]
[[package]]
@@ -1634,6 +1635,15 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
]
[[package]]
name = "html5ever"
version = "0.29.1"
@@ -2348,6 +2358,8 @@ dependencies = [
"chrono",
"dirs 5.0.1",
"futures-util",
"hex",
"hmac",
"lazy_static",
"md5",
"num_cpus",
@@ -2359,6 +2371,7 @@ dependencies = [
"serde",
"serde_json",
"serde_yaml",
"sha2",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
@@ -2375,6 +2388,7 @@ dependencies = [
"tracing-subscriber",
"tree-sitter",
"tree-sitter-json",
"url",
"uuid",
"winapi",
]
@@ -4108,6 +4122,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "swift-rs"
version = "1.0.7"

View File

@@ -49,6 +49,10 @@ num_cpus = "1.16"
tokio-tungstenite = "0.20"
futures-util = "0.3"
rand = "0.8"
hmac = "0.12.1"
sha2 = "0.10.9"
hex = "0.4.3"
url = "2.5.4"
[target.'cfg(windows)'.dependencies]
winapi = { version = "0.3", features = ["sysinfoapi"] }

View File

@@ -330,7 +330,6 @@ impl ComfyUIService {
"model" => {
// 确保使用CDN格式的URL
let cdn_url = self.convert_to_cdn_url(model_image_url);
let filename = self.extract_filename_from_url(&cdn_url);
// 替换image_url字段
replacements.push(WorkflowNodeReplacement {
@@ -339,17 +338,10 @@ impl ComfyUIService {
value: Value::String(cdn_url),
});
// 替换image字段为文件名
replacements.push(WorkflowNodeReplacement {
node_id: node_id.clone(),
input_field: "image".to_string(),
value: Value::String(filename),
});
}
"product" => {
// 确保使用CDN格式的URL
let cdn_url = self.convert_to_cdn_url(product_image_url);
let filename = self.extract_filename_from_url(&cdn_url);
// 替换image_url字段
replacements.push(WorkflowNodeReplacement {
@@ -358,12 +350,6 @@ impl ComfyUIService {
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 不为空时才进行替换,保持工作流原始设置

View File

@@ -39,6 +39,7 @@ pub mod comfyui_service;
pub mod outfit_photo_generation_service;
pub mod workflow_management_service;
pub mod error_handling_service;
pub mod volcano_video_service;
#[cfg(test)]
pub mod tests;

View File

@@ -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, &timestamp, &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, &timestamp, &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(),
}
}
}

View File

@@ -26,3 +26,4 @@ pub mod speech_generation_record;
pub mod system_voice;
pub mod outfit_image;
pub mod outfit_photo_generation;
pub mod video_generation_record;

View File

@@ -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),
}
}
}

View File

@@ -235,7 +235,6 @@ impl ConversationRepository {
// 使用专用的只读连接,避免与写操作竞争
match self.database.try_get_read_connection() {
Some(conn) => {
println!("✅ 成功获取只读连接");
self.execute_history_query(&conn, query)
},
None => {

View File

@@ -18,3 +18,4 @@ pub mod image_generation_repository;
pub mod system_voice_repository;
pub mod outfit_image_repository;
pub mod outfit_photo_generation_repository;
pub mod video_generation_record_repository;

View File

@@ -95,13 +95,11 @@ impl ModelRepository {
/// 根据ID获取模特
/// 使用读写分离避免嵌套锁问题
pub fn get_by_id(&self, id: &str) -> Result<Option<Model>> {
println!("get_by_id 开始执行model_id: {}", id);
// 首先获取模特基本信息(使用只读连接)
let model = {
match self.database.try_get_read_connection() {
Some(conn) => {
println!("get_by_id 使用只读连接获取基本信息");
let mut stmt = conn.prepare(
"SELECT id, name, stage_name, gender, age, height, weight, measurements,
@@ -129,12 +127,9 @@ impl ModelRepository {
// 如果找到模特,加载照片信息(此时已释放了基本信息查询的锁)
if let Some(mut model) = model {
println!("get_by_id 开始加载照片信息");
model.photos = self.get_photos(&model.id)?;
println!("get_by_id 执行完成");
Ok(Some(model))
} else {
println!("get_by_id 未找到模特");
Ok(None)
}
}
@@ -498,7 +493,6 @@ impl ModelRepository {
/// 获取模特照片
/// 使用专用只读连接,避免与写操作的锁竞争
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() {
@@ -517,7 +511,6 @@ impl ModelRepository {
/// 执行照片查询的具体实现
fn execute_photo_query(&self, conn: &Connection, model_id: &str) -> Result<Vec<ModelPhoto>> {
println!("get_photos 开始准备SQL语句");
let mut stmt = conn.prepare(
"SELECT id, model_id, file_path, file_name, file_size, photo_type,
@@ -527,7 +520,6 @@ impl ModelRepository {
println!("get_photos SQL 语句准备成功");
let photo_iter = stmt.query_map([model_id], |row| self.row_to_photo(row))?;
println!("get_photos 查询执行成功");
let mut photos = Vec::new();
for (index, photo_result) in photo_iter.enumerate() {
@@ -545,7 +537,6 @@ impl ModelRepository {
}
}
println!("get_photos 查询处理完成,共 {} 张照片", photos.len());
Ok(photos)
}
@@ -735,10 +726,7 @@ impl ModelRepository {
/// 将数据库行转换为照片对象
fn row_to_photo(&self, row: &Row) -> Result<ModelPhoto> {
println!("row_to_photo 开始执行");
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| {
println!("row_to_photo photo_type 解析失败: {}", e);

View File

@@ -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(&param_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(),
})
}
}

View File

@@ -245,7 +245,6 @@ impl ConnectionPool {
if let Some(index) = found_index {
let pooled_conn = connections.remove(index).unwrap();
println!("从连接池获取现有连接,剩余连接数: {}", connections.len());
return Ok(Some(pooled_conn.connection));
}

View File

@@ -221,22 +221,6 @@ impl MigrationManager {
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: 创建语音生成记录表
self.add_migration(Migration {
version: 24,
@@ -245,12 +229,37 @@ impl MigrationManager {
down_sql: None, // 暂时不提供回滚脚本
});
// 迁移 25: 创建穿搭照片生成记录表
// 迁移 25: 创建声音克隆记录表
self.add_migration(Migration {
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(),
up_sql: include_str!("migrations/025_create_outfit_photo_generations_table.sql").to_string(),
down_sql: Some(include_str!("migrations/025_create_outfit_photo_generations_table_down.sql").to_string()),
up_sql: include_str!("migrations/026_create_outfit_photo_generations_table.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()),
});
}

View File

@@ -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);

View File

@@ -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;

View File

@@ -490,7 +490,17 @@ pub fn run() {
commands::outfit_photo_generation_commands::delete_outfit_photo_generation,
commands::outfit_photo_generation_commands::regenerate_outfit_photo,
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| {
// 初始化日志系统

View File

@@ -50,7 +50,6 @@ pub async fn get_directory_settings() -> Result<DirectorySettingsResponse, Strin
match service.get_directory_settings() {
Ok(settings) => {
info!("成功获取目录设置");
Ok(DirectorySettingsResponse::from(settings))
}
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) {
Ok(setting) => {
info!("成功获取目录设置 {}: {:?}", setting_type, setting);
Ok(setting)
}
Err(e) => {

View File

@@ -39,3 +39,4 @@ pub mod outfit_image_commands;
pub mod outfit_photo_generation_commands;
pub mod workflow_management_commands;
pub mod error_handling_commands;
pub mod volcano_video_commands;

View File

@@ -44,8 +44,6 @@ pub async fn get_model_dashboard_stats(
state: State<'_, AppState>,
model_id: String,
) -> Result<ModelDashboardStats, String> {
println!("📊 获取模特个人看板统计信息: {}", model_id);
// 获取数据库连接
let database = state.get_database();
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)
.map_err(|e| format!("获取穿搭图片记录失败: {}", e))?;
println!("✅ 获取到 {} 条穿搭图片记录", records.len());
Ok(records)
}
@@ -130,7 +127,6 @@ pub async fn get_outfit_image_records_paginated(
page: u32,
page_size: u32,
) -> Result<OutfitImageRecordsResponse, String> {
println!("📋 分页获取模特穿搭图片生成记录: {} (页码: {}, 每页: {})", model_id, page, page_size);
let database = state.get_database();
let outfit_repo = OutfitImageRepository::new(database);
@@ -157,8 +153,6 @@ pub async fn get_outfit_image_records_paginated(
has_more,
};
println!("✅ 获取到 {} 条穿搭图片记录 (总数: {}, 还有更多: {})",
response.records.len(), total_count, has_more);
Ok(response)
}

View File

@@ -34,13 +34,11 @@ pub struct SystemVoiceResponse {
pub async fn get_system_voices(
database: State<'_, Arc<Database>>,
) -> Result<Vec<SystemVoice>, String> {
info!("获取系统音色列表");
let repository = SystemVoiceRepository::new(database.inner().clone());
match repository.get_all_active() {
Ok(voices) => {
info!("成功获取 {} 个系统音色", voices.len());
Ok(voices)
}
Err(e) => {
@@ -63,7 +61,6 @@ pub async fn get_system_voices_by_type(
match repository.get_by_type(voice_type_enum) {
Ok(voices) => {
info!("成功获取 {} 个 {} 类型的系统音色", voices.len(), voice_type);
Ok(voices)
}
Err(e) => {
@@ -85,7 +82,6 @@ pub async fn get_system_voices_by_gender(
match repository.get_by_gender(&gender) {
Ok(voices) => {
info!("成功获取 {} 个 {} 性别的系统音色", voices.len(), gender);
Ok(voices)
}
Err(e) => {
@@ -107,7 +103,6 @@ pub async fn get_system_voices_by_language(
match repository.get_by_language(&language) {
Ok(voices) => {
info!("成功获取 {} 个 {} 语言的系统音色", voices.len(), language);
Ok(voices)
}
Err(e) => {
@@ -188,7 +183,6 @@ pub async fn get_system_voices_paginated(
total_pages,
};
info!("成功获取分页系统音色,页码: {}, 每页: {}, 总数: {}", page, page_size, total);
Ok(response)
}
Err(e) => {
@@ -210,11 +204,6 @@ pub async fn get_system_voice_by_id(
match repository.get_by_voice_id(&voice_id) {
Ok(voice) => {
if voice.is_some() {
info!("成功获取系统音色详情: {}", voice_id);
} else {
info!("未找到系统音色: {}", voice_id);
}
Ok(voice)
}
Err(e) => {
@@ -297,6 +286,5 @@ pub async fn get_system_voice_stats(
"by_gender": gender_stats
});
info!("成功获取系统音色统计信息");
Ok(stats)
}

View File

@@ -660,13 +660,11 @@ pub async fn get_speech_generation_records(
database: State<'_, Arc<Database>>,
limit: Option<i32>,
) -> Result<Vec<SpeechGenerationRecord>, String> {
info!("获取语音生成记录列表, limit: {:?}", limit);
match database.inner().with_connection(|conn| {
SpeechGenerationRecord::get_all(conn, limit)
}) {
Ok(records) => {
info!("成功获取 {} 条语音生成记录", records.len());
Ok(records)
}
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)
}) {
Ok(records) => {
info!("成功获取音色 {} 的 {} 条语音生成记录", voice_id, records.len());
Ok(records)
}
Err(e) => {

View File

@@ -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))
}
}
}

View File

@@ -27,6 +27,8 @@ import OutfitComparisonTool from './pages/tools/OutfitComparisonTool';
import MaterialSearchTool from './pages/tools/MaterialSearchTool';
import ImageGenerationTool from './pages/tools/ImageGenerationTool';
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 MaterialCenter from './pages/MaterialCenter';
import VideoGeneration from './pages/VideoGeneration';
@@ -141,7 +143,9 @@ function App() {
<Route path="/tools/outfit-comparison" element={<OutfitComparisonTool />} />
<Route path="/tools/material-search" element={<MaterialSearchTool />} />
<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/enriched-analysis-demo" element={<EnrichedAnalysisDemo />} />
</Routes>

View 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;

View File

@@ -8,7 +8,8 @@ import {
Heart,
ArrowLeftRight,
Image,
Mic
Mic,
Video
} from 'lucide-react';
import { Tool, ToolCategory, ToolStatus } from '../types/tool';
@@ -136,6 +137,21 @@ export const TOOLS_DATA: Tool[] = [
isPopular: true,
version: '1.0.0',
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'
}
];

View 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;

View File

@@ -18,8 +18,6 @@ export class OutfitImageService {
*/
static async getModelDashboardStats(modelId: string): Promise<ModelDashboardStats> {
try {
console.log('📊 获取模特个人看板统计信息:', modelId);
const stats = await invoke<ModelDashboardStats>('get_model_dashboard_stats', {
modelId
});
@@ -59,7 +57,6 @@ export class OutfitImageService {
pageSize: number = 20
): Promise<OutfitImageRecordsResponse> {
try {
console.log('📋 分页获取模特穿搭图片生成记录:', modelId, `页码: ${page}, 每页: ${pageSize}`);
const response = await invoke<OutfitImageRecordsResponse>('get_outfit_image_records_paginated', {
modelId,