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 = [
|
||||
"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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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 不为空时才进行替换,保持工作流原始设置
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 outfit_image;
|
||||
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() {
|
||||
Some(conn) => {
|
||||
println!("✅ 成功获取只读连接");
|
||||
self.execute_history_query(&conn, query)
|
||||
},
|
||||
None => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
let pooled_conn = connections.remove(index).unwrap();
|
||||
println!("从连接池获取现有连接,剩余连接数: {}", connections.len());
|
||||
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()),
|
||||
});
|
||||
|
||||
// 迁移 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()),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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::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| {
|
||||
// 初始化日志系统
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 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>
|
||||
|
||||
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,
|
||||
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'
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
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> {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user