feat: 完善水印处理工具功能
- 修复批量处理进度条不更新问题 - 实现任务状态管理器(task_manager.rs) - 添加实时进度更新和任务状态跟踪 - 完善前端进度轮询逻辑 - 丰富水印检测结果展示 - 添加详细检测结果信息(位置、置信度、类型等) - 显示处理统计和性能信息 - 优化结果展示UI和用户体验 - 修复水印模板上传和删除功能 - 实现内存存储系统管理模板 - 修复参数匹配问题 - 添加模板缩略图展示功能 - 优化文件上传体验 - 使用Tauri Dialog API替代HTML5文件输入 - 实现原生文件选择器 - 添加WatermarkTemplateThumbnail组件 - 完善水印工具集成 - 创建WatermarkTool页面 - 添加到便捷工具列表 - 完善路由配置和UI展示
This commit is contained in:
@@ -172,6 +172,10 @@ pub enum BusinessError {
|
||||
InvalidState(String),
|
||||
}
|
||||
|
||||
/// 水印处理相关错误
|
||||
pub mod watermark_errors;
|
||||
pub use watermark_errors::*;
|
||||
|
||||
/// 错误结果类型别名
|
||||
pub type AppResult<T> = Result<T, AppError>;
|
||||
pub type MaterialResult<T> = Result<T, MaterialError>;
|
||||
|
||||
455
apps/desktop/src-tauri/src/business/errors/watermark_errors.rs
Normal file
455
apps/desktop/src-tauri/src/business/errors/watermark_errors.rs
Normal file
@@ -0,0 +1,455 @@
|
||||
use thiserror::Error;
|
||||
|
||||
/// 水印处理相关错误类型
|
||||
/// 遵循 Tauri 开发规范的错误处理设计
|
||||
#[derive(Debug, Error)]
|
||||
pub enum WatermarkError {
|
||||
#[error("水印检测失败: {message}")]
|
||||
DetectionFailed { message: String },
|
||||
|
||||
#[error("水印移除失败: {message}")]
|
||||
RemovalFailed { message: String },
|
||||
|
||||
#[error("水印添加失败: {message}")]
|
||||
AdditionFailed { message: String },
|
||||
|
||||
#[error("不支持的文件格式: {format}, 支持的格式: {supported:?}")]
|
||||
UnsupportedFormat { format: String, supported: Vec<String> },
|
||||
|
||||
#[error("FFmpeg执行错误: {message}")]
|
||||
FFmpegError { message: String },
|
||||
|
||||
#[error("OpenCV处理错误: {message}")]
|
||||
OpenCVError { message: String },
|
||||
|
||||
#[error("模板不存在: {template_id}")]
|
||||
TemplateNotFound { template_id: String },
|
||||
|
||||
#[error("模板名称已存在: {name}")]
|
||||
TemplateNameExists { name: String },
|
||||
|
||||
#[error("无效的水印配置: {message}")]
|
||||
InvalidConfig { message: String },
|
||||
|
||||
#[error("文件操作失败: {operation}, 路径: {path}, 错误: {message}")]
|
||||
FileOperationFailed {
|
||||
operation: String,
|
||||
path: String,
|
||||
message: String,
|
||||
},
|
||||
|
||||
#[error("批量任务失败: {task_id}, 错误: {message}")]
|
||||
BatchTaskFailed { task_id: String, message: String },
|
||||
|
||||
#[error("任务已取消: {task_id}")]
|
||||
TaskCancelled { task_id: String },
|
||||
|
||||
#[error("任务超时: {task_id}, 超时时间: {timeout_ms}ms")]
|
||||
TaskTimeout { task_id: String, timeout_ms: u64 },
|
||||
|
||||
#[error("数据库操作失败: {operation}, 错误: {message}")]
|
||||
DatabaseError { operation: String, message: String },
|
||||
|
||||
#[error("网络请求失败: {url}, 错误: {message}")]
|
||||
NetworkError { url: String, message: String },
|
||||
|
||||
#[error("权限不足: {operation}")]
|
||||
PermissionDenied { operation: String },
|
||||
|
||||
#[error("资源不足: {resource}, 需要: {required}, 可用: {available}")]
|
||||
InsufficientResources {
|
||||
resource: String,
|
||||
required: String,
|
||||
available: String,
|
||||
},
|
||||
|
||||
#[error("配置错误: {key}, 值: {value}, 错误: {message}")]
|
||||
ConfigurationError {
|
||||
key: String,
|
||||
value: String,
|
||||
message: String,
|
||||
},
|
||||
|
||||
#[error("验证失败: {field}, 值: {value}, 原因: {reason}")]
|
||||
ValidationError {
|
||||
field: String,
|
||||
value: String,
|
||||
reason: String,
|
||||
},
|
||||
|
||||
#[error("内部错误: {message}")]
|
||||
InternalError { message: String },
|
||||
|
||||
#[error("外部依赖错误: {dependency}, 错误: {message}")]
|
||||
ExternalDependencyError { dependency: String, message: String },
|
||||
}
|
||||
|
||||
impl WatermarkError {
|
||||
/// 创建检测失败错误
|
||||
pub fn detection_failed<S: Into<String>>(message: S) -> Self {
|
||||
Self::DetectionFailed {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建移除失败错误
|
||||
pub fn removal_failed<S: Into<String>>(message: S) -> Self {
|
||||
Self::RemovalFailed {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建添加失败错误
|
||||
pub fn addition_failed<S: Into<String>>(message: S) -> Self {
|
||||
Self::AdditionFailed {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建不支持格式错误
|
||||
pub fn unsupported_format<S: Into<String>>(format: S, supported: Vec<String>) -> Self {
|
||||
Self::UnsupportedFormat {
|
||||
format: format.into(),
|
||||
supported,
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建FFmpeg错误
|
||||
pub fn ffmpeg_error<S: Into<String>>(message: S) -> Self {
|
||||
Self::FFmpegError {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建OpenCV错误
|
||||
pub fn opencv_error<S: Into<String>>(message: S) -> Self {
|
||||
Self::OpenCVError {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建模板不存在错误
|
||||
pub fn template_not_found<S: Into<String>>(template_id: S) -> Self {
|
||||
Self::TemplateNotFound {
|
||||
template_id: template_id.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建模板名称已存在错误
|
||||
pub fn template_name_exists<S: Into<String>>(name: S) -> Self {
|
||||
Self::TemplateNameExists { name: name.into() }
|
||||
}
|
||||
|
||||
/// 创建无效配置错误
|
||||
pub fn invalid_config<S: Into<String>>(message: S) -> Self {
|
||||
Self::InvalidConfig {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建文件操作失败错误
|
||||
pub fn file_operation_failed<S: Into<String>>(
|
||||
operation: S,
|
||||
path: S,
|
||||
message: S,
|
||||
) -> Self {
|
||||
Self::FileOperationFailed {
|
||||
operation: operation.into(),
|
||||
path: path.into(),
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建批量任务失败错误
|
||||
pub fn batch_task_failed<S: Into<String>>(task_id: S, message: S) -> Self {
|
||||
Self::BatchTaskFailed {
|
||||
task_id: task_id.into(),
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建任务取消错误
|
||||
pub fn task_cancelled<S: Into<String>>(task_id: S) -> Self {
|
||||
Self::TaskCancelled {
|
||||
task_id: task_id.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建任务超时错误
|
||||
pub fn task_timeout<S: Into<String>>(task_id: S, timeout_ms: u64) -> Self {
|
||||
Self::TaskTimeout {
|
||||
task_id: task_id.into(),
|
||||
timeout_ms,
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建数据库错误
|
||||
pub fn database_error<S: Into<String>>(operation: S, message: S) -> Self {
|
||||
Self::DatabaseError {
|
||||
operation: operation.into(),
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建网络错误
|
||||
pub fn network_error<S: Into<String>>(url: S, message: S) -> Self {
|
||||
Self::NetworkError {
|
||||
url: url.into(),
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建权限不足错误
|
||||
pub fn permission_denied<S: Into<String>>(operation: S) -> Self {
|
||||
Self::PermissionDenied {
|
||||
operation: operation.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建资源不足错误
|
||||
pub fn insufficient_resources<S: Into<String>>(
|
||||
resource: S,
|
||||
required: S,
|
||||
available: S,
|
||||
) -> Self {
|
||||
Self::InsufficientResources {
|
||||
resource: resource.into(),
|
||||
required: required.into(),
|
||||
available: available.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建配置错误
|
||||
pub fn configuration_error<S: Into<String>>(key: S, value: S, message: S) -> Self {
|
||||
Self::ConfigurationError {
|
||||
key: key.into(),
|
||||
value: value.into(),
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建验证错误
|
||||
pub fn validation_error<S: Into<String>>(field: S, value: S, reason: S) -> Self {
|
||||
Self::ValidationError {
|
||||
field: field.into(),
|
||||
value: value.into(),
|
||||
reason: reason.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建内部错误
|
||||
pub fn internal_error<S: Into<String>>(message: S) -> Self {
|
||||
Self::InternalError {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建外部依赖错误
|
||||
pub fn external_dependency_error<S: Into<String>>(dependency: S, message: S) -> Self {
|
||||
Self::ExternalDependencyError {
|
||||
dependency: dependency.into(),
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取错误类型
|
||||
pub fn error_type(&self) -> &'static str {
|
||||
match self {
|
||||
Self::DetectionFailed { .. } => "detection_failed",
|
||||
Self::RemovalFailed { .. } => "removal_failed",
|
||||
Self::AdditionFailed { .. } => "addition_failed",
|
||||
Self::UnsupportedFormat { .. } => "unsupported_format",
|
||||
Self::FFmpegError { .. } => "ffmpeg_error",
|
||||
Self::OpenCVError { .. } => "opencv_error",
|
||||
Self::TemplateNotFound { .. } => "template_not_found",
|
||||
Self::TemplateNameExists { .. } => "template_name_exists",
|
||||
Self::InvalidConfig { .. } => "invalid_config",
|
||||
Self::FileOperationFailed { .. } => "file_operation_failed",
|
||||
Self::BatchTaskFailed { .. } => "batch_task_failed",
|
||||
Self::TaskCancelled { .. } => "task_cancelled",
|
||||
Self::TaskTimeout { .. } => "task_timeout",
|
||||
Self::DatabaseError { .. } => "database_error",
|
||||
Self::NetworkError { .. } => "network_error",
|
||||
Self::PermissionDenied { .. } => "permission_denied",
|
||||
Self::InsufficientResources { .. } => "insufficient_resources",
|
||||
Self::ConfigurationError { .. } => "configuration_error",
|
||||
Self::ValidationError { .. } => "validation_error",
|
||||
Self::InternalError { .. } => "internal_error",
|
||||
Self::ExternalDependencyError { .. } => "external_dependency_error",
|
||||
}
|
||||
}
|
||||
|
||||
/// 判断是否为可重试错误
|
||||
pub fn is_retryable(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::NetworkError { .. }
|
||||
| Self::TaskTimeout { .. }
|
||||
| Self::InsufficientResources { .. }
|
||||
| Self::ExternalDependencyError { .. }
|
||||
)
|
||||
}
|
||||
|
||||
/// 判断是否为用户错误
|
||||
pub fn is_user_error(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::UnsupportedFormat { .. }
|
||||
| Self::TemplateNotFound { .. }
|
||||
| Self::TemplateNameExists { .. }
|
||||
| Self::InvalidConfig { .. }
|
||||
| Self::ValidationError { .. }
|
||||
| Self::PermissionDenied { .. }
|
||||
)
|
||||
}
|
||||
|
||||
/// 判断是否为系统错误
|
||||
pub fn is_system_error(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::FFmpegError { .. }
|
||||
| Self::OpenCVError { .. }
|
||||
| Self::FileOperationFailed { .. }
|
||||
| Self::DatabaseError { .. }
|
||||
| Self::InternalError { .. }
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取错误的严重程度
|
||||
pub fn severity(&self) -> ErrorSeverity {
|
||||
match self {
|
||||
Self::ValidationError { .. } | Self::InvalidConfig { .. } => ErrorSeverity::Warning,
|
||||
Self::TemplateNotFound { .. }
|
||||
| Self::TemplateNameExists { .. }
|
||||
| Self::UnsupportedFormat { .. }
|
||||
| Self::PermissionDenied { .. } => ErrorSeverity::Error,
|
||||
Self::DetectionFailed { .. }
|
||||
| Self::RemovalFailed { .. }
|
||||
| Self::AdditionFailed { .. }
|
||||
| Self::BatchTaskFailed { .. }
|
||||
| Self::TaskTimeout { .. } => ErrorSeverity::Error,
|
||||
Self::FFmpegError { .. }
|
||||
| Self::OpenCVError { .. }
|
||||
| Self::DatabaseError { .. }
|
||||
| Self::FileOperationFailed { .. }
|
||||
| Self::NetworkError { .. }
|
||||
| Self::InsufficientResources { .. }
|
||||
| Self::ExternalDependencyError { .. }
|
||||
| Self::InternalError { .. } => ErrorSeverity::Critical,
|
||||
Self::TaskCancelled { .. } => ErrorSeverity::Info,
|
||||
Self::ConfigurationError { .. } => ErrorSeverity::Warning,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 错误严重程度
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ErrorSeverity {
|
||||
Info,
|
||||
Warning,
|
||||
Error,
|
||||
Critical,
|
||||
}
|
||||
|
||||
impl ErrorSeverity {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Info => "info",
|
||||
Self::Warning => "warning",
|
||||
Self::Error => "error",
|
||||
Self::Critical => "critical",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 错误上下文信息
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ErrorContext {
|
||||
pub operation: String,
|
||||
pub material_id: Option<String>,
|
||||
pub template_id: Option<String>,
|
||||
pub task_id: Option<String>,
|
||||
pub file_path: Option<String>,
|
||||
pub timestamp: chrono::DateTime<chrono::Utc>,
|
||||
pub additional_info: std::collections::HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl ErrorContext {
|
||||
pub fn new<S: Into<String>>(operation: S) -> Self {
|
||||
Self {
|
||||
operation: operation.into(),
|
||||
material_id: None,
|
||||
template_id: None,
|
||||
task_id: None,
|
||||
file_path: None,
|
||||
timestamp: chrono::Utc::now(),
|
||||
additional_info: std::collections::HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_material_id<S: Into<String>>(mut self, material_id: S) -> Self {
|
||||
self.material_id = Some(material_id.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_template_id<S: Into<String>>(mut self, template_id: S) -> Self {
|
||||
self.template_id = Some(template_id.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_task_id<S: Into<String>>(mut self, task_id: S) -> Self {
|
||||
self.task_id = Some(task_id.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_file_path<S: Into<String>>(mut self, file_path: S) -> Self {
|
||||
self.file_path = Some(file_path.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_info<K: Into<String>, V: Into<String>>(mut self, key: K, value: V) -> Self {
|
||||
self.additional_info.insert(key.into(), value.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// 水印错误结果类型
|
||||
pub type WatermarkResult<T> = Result<T, WatermarkError>;
|
||||
|
||||
/// 从标准错误转换为水印错误
|
||||
impl From<std::io::Error> for WatermarkError {
|
||||
fn from(err: std::io::Error) -> Self {
|
||||
Self::FileOperationFailed {
|
||||
operation: "io_operation".to_string(),
|
||||
path: "unknown".to_string(),
|
||||
message: err.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<rusqlite::Error> for WatermarkError {
|
||||
fn from(err: rusqlite::Error) -> Self {
|
||||
Self::DatabaseError {
|
||||
operation: "database_operation".to_string(),
|
||||
message: err.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for WatermarkError {
|
||||
fn from(err: serde_json::Error) -> Self {
|
||||
Self::ConfigurationError {
|
||||
key: "json_config".to_string(),
|
||||
value: "unknown".to_string(),
|
||||
message: err.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<anyhow::Error> for WatermarkError {
|
||||
fn from(err: anyhow::Error) -> Self {
|
||||
Self::InternalError {
|
||||
message: err.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
use anyhow::{Result, anyhow};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use tracing::{info, warn, error, debug};
|
||||
|
||||
use crate::data::models::watermark::{
|
||||
BatchWatermarkTask, WatermarkOperation, BatchTaskStatus, BatchProgress,
|
||||
WatermarkProcessingResult, WatermarkConfig, WatermarkRemovalConfig,
|
||||
WatermarkDetectionConfig
|
||||
};
|
||||
use crate::data::repositories::material_repository::MaterialRepository;
|
||||
use crate::business::services::{
|
||||
watermark_detection_service::WatermarkDetectionService,
|
||||
watermark_removal_service::WatermarkRemovalService,
|
||||
watermark_addition_service::WatermarkAdditionService,
|
||||
task_manager::TASK_MANAGER,
|
||||
};
|
||||
// use crate::infrastructure::event_bus::EventBusManager;
|
||||
use crate::infrastructure::monitoring::PERFORMANCE_MONITOR;
|
||||
|
||||
/// 批量水印处理器
|
||||
/// 遵循 Tauri 开发规范的异步业务逻辑层设计
|
||||
pub struct BatchWatermarkProcessor;
|
||||
|
||||
impl BatchWatermarkProcessor {
|
||||
/// 启动批量水印处理任务
|
||||
pub async fn start_batch_task(
|
||||
task: BatchWatermarkTask,
|
||||
repository: Arc<MaterialRepository>,
|
||||
) -> Result<()> {
|
||||
let timer = PERFORMANCE_MONITOR.start_operation("batch_watermark_processing");
|
||||
let start_time = Instant::now();
|
||||
|
||||
info!(
|
||||
task_id = %task.task_id,
|
||||
operation = ?task.operation,
|
||||
material_count = task.material_ids.len(),
|
||||
"开始批量水印处理任务"
|
||||
);
|
||||
|
||||
// 将任务添加到任务管理器
|
||||
TASK_MANAGER.add_task(task.clone())?;
|
||||
|
||||
// 更新任务状态为运行中
|
||||
TASK_MANAGER.update_task_status(&task.task_id, BatchTaskStatus::Running)?;
|
||||
|
||||
// TODO: 发布任务开始事件
|
||||
|
||||
let mut progress = BatchProgress {
|
||||
total_items: task.material_ids.len() as u32,
|
||||
processed_items: 0,
|
||||
failed_items: 0,
|
||||
current_item: None,
|
||||
progress_percentage: 0.0,
|
||||
estimated_remaining_ms: None,
|
||||
errors: Vec::new(),
|
||||
detection_results: Vec::new(),
|
||||
processing_results: Vec::new(),
|
||||
};
|
||||
|
||||
let mut results = Vec::new();
|
||||
|
||||
// 处理每个素材
|
||||
for (index, material_id) in task.material_ids.iter().enumerate() {
|
||||
progress.current_item = Some(material_id.clone());
|
||||
progress.progress_percentage = (index as f32 / task.material_ids.len() as f32) * 100.0;
|
||||
|
||||
// 更新任务管理器中的进度
|
||||
TASK_MANAGER.update_task_progress(&task.task_id, progress.clone())?;
|
||||
|
||||
// TODO: 发布进度更新事件
|
||||
|
||||
debug!(
|
||||
task_id = %task.task_id,
|
||||
material_id = %material_id,
|
||||
progress = progress.progress_percentage,
|
||||
"处理素材"
|
||||
);
|
||||
|
||||
// 处理单个素材
|
||||
let result = Self::process_single_material(
|
||||
material_id,
|
||||
&task.operation,
|
||||
&task.config,
|
||||
repository.clone(),
|
||||
).await;
|
||||
|
||||
match result {
|
||||
Ok(processing_result) => {
|
||||
if processing_result.success {
|
||||
progress.processed_items += 1;
|
||||
|
||||
// 如果是检测操作,提取检测结果
|
||||
if task.operation == WatermarkOperation::Detect {
|
||||
if let Some(metadata) = &processing_result.metadata {
|
||||
if let Ok(detection_result) = serde_json::from_value::<crate::data::models::watermark::WatermarkDetectionResult>(metadata.clone()) {
|
||||
progress.detection_results.push(detection_result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 添加处理结果
|
||||
progress.processing_results.push(processing_result.clone());
|
||||
|
||||
info!(
|
||||
task_id = %task.task_id,
|
||||
material_id = %material_id,
|
||||
"素材处理成功"
|
||||
);
|
||||
} else {
|
||||
progress.failed_items += 1;
|
||||
if let Some(error) = &processing_result.error_message {
|
||||
progress.errors.push(format!("{}: {}", material_id, error));
|
||||
}
|
||||
warn!(
|
||||
task_id = %task.task_id,
|
||||
material_id = %material_id,
|
||||
error = ?processing_result.error_message,
|
||||
"素材处理失败"
|
||||
);
|
||||
}
|
||||
results.push(processing_result);
|
||||
}
|
||||
Err(e) => {
|
||||
progress.failed_items += 1;
|
||||
progress.errors.push(format!("{}: {}", material_id, e));
|
||||
error!(
|
||||
task_id = %task.task_id,
|
||||
material_id = %material_id,
|
||||
error = %e,
|
||||
"素材处理异常"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 计算预估剩余时间
|
||||
if index > 0 {
|
||||
let elapsed = start_time.elapsed().as_millis() as u64;
|
||||
let avg_time_per_item = elapsed / (index + 1) as u64;
|
||||
let remaining_items = task.material_ids.len() - index - 1;
|
||||
progress.estimated_remaining_ms = Some(avg_time_per_item * remaining_items as u64);
|
||||
}
|
||||
|
||||
// 更新处理后的进度
|
||||
progress.progress_percentage = ((index + 1) as f32 / task.material_ids.len() as f32) * 100.0;
|
||||
TASK_MANAGER.update_task_progress(&task.task_id, progress.clone())?;
|
||||
}
|
||||
|
||||
// 完成处理
|
||||
progress.current_item = None;
|
||||
progress.progress_percentage = 100.0;
|
||||
progress.estimated_remaining_ms = Some(0);
|
||||
|
||||
let final_status = if progress.failed_items == 0 {
|
||||
BatchTaskStatus::Completed
|
||||
} else if progress.processed_items == 0 {
|
||||
BatchTaskStatus::Failed
|
||||
} else {
|
||||
BatchTaskStatus::Completed // 部分成功也算完成
|
||||
};
|
||||
|
||||
// 更新最终状态
|
||||
TASK_MANAGER.update_task_status(&task.task_id, final_status.clone())?;
|
||||
TASK_MANAGER.update_task_progress(&task.task_id, progress.clone())?;
|
||||
|
||||
let processing_time = start_time.elapsed().as_millis() as u64;
|
||||
|
||||
// TODO: 发布任务完成事件
|
||||
|
||||
info!(
|
||||
task_id = %task.task_id,
|
||||
processed_items = progress.processed_items,
|
||||
failed_items = progress.failed_items,
|
||||
processing_time_ms = processing_time,
|
||||
"批量水印处理任务完成"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 处理单个素材
|
||||
async fn process_single_material(
|
||||
material_id: &str,
|
||||
operation: &WatermarkOperation,
|
||||
config: &serde_json::Value,
|
||||
repository: Arc<MaterialRepository>,
|
||||
) -> Result<WatermarkProcessingResult> {
|
||||
// 获取素材信息
|
||||
let material = repository.get_by_id(material_id)?
|
||||
.ok_or_else(|| anyhow!("素材不存在: {}", material_id))?;
|
||||
|
||||
let input_path = &material.original_path;
|
||||
let output_dir = Self::get_output_directory(&material.project_id, operation);
|
||||
std::fs::create_dir_all(&output_dir)?;
|
||||
|
||||
let output_filename = Self::generate_output_filename(&material.name, operation);
|
||||
let output_path = format!("{}/{}", output_dir, output_filename);
|
||||
|
||||
match operation {
|
||||
WatermarkOperation::Detect => {
|
||||
let detection_config: WatermarkDetectionConfig = serde_json::from_value(config.clone())?;
|
||||
|
||||
let detection_result = if format!("{:?}", material.material_type).to_lowercase().contains("video") {
|
||||
WatermarkDetectionService::detect_watermarks_in_video(
|
||||
material_id,
|
||||
input_path,
|
||||
&detection_config,
|
||||
repository.clone(),
|
||||
).await?
|
||||
} else {
|
||||
WatermarkDetectionService::detect_watermarks_in_image(
|
||||
material_id,
|
||||
input_path,
|
||||
&detection_config,
|
||||
).await?
|
||||
};
|
||||
|
||||
// 保存检测结果到数据库
|
||||
Self::save_detection_result(&detection_result, &repository).await?;
|
||||
|
||||
Ok(WatermarkProcessingResult {
|
||||
material_id: material_id.to_string(),
|
||||
operation: operation.clone(),
|
||||
success: true,
|
||||
output_path: None, // 检测不产生输出文件
|
||||
processing_time_ms: detection_result.processing_time_ms,
|
||||
error_message: None,
|
||||
metadata: Some(serde_json::to_value(detection_result)?),
|
||||
})
|
||||
}
|
||||
WatermarkOperation::Remove => {
|
||||
let removal_config: WatermarkRemovalConfig = serde_json::from_value(config.clone())?;
|
||||
|
||||
if format!("{:?}", material.material_type).to_lowercase().contains("video") {
|
||||
WatermarkRemovalService::remove_watermarks_from_video(
|
||||
material_id,
|
||||
input_path,
|
||||
&output_path,
|
||||
&removal_config,
|
||||
repository.clone(),
|
||||
).await
|
||||
} else {
|
||||
WatermarkRemovalService::remove_watermarks_from_image(
|
||||
material_id,
|
||||
input_path,
|
||||
&output_path,
|
||||
&removal_config,
|
||||
).await
|
||||
}
|
||||
}
|
||||
WatermarkOperation::Add => {
|
||||
let watermark_config: WatermarkConfig = serde_json::from_value(config.clone())?;
|
||||
let watermark_path = Self::get_watermark_path_from_config(&watermark_config)?;
|
||||
|
||||
if format!("{:?}", material.material_type).to_lowercase().contains("video") {
|
||||
WatermarkAdditionService::add_watermark_to_video(
|
||||
material_id,
|
||||
input_path,
|
||||
&output_path,
|
||||
&watermark_path,
|
||||
&watermark_config,
|
||||
repository.clone(),
|
||||
).await
|
||||
} else {
|
||||
WatermarkAdditionService::add_watermark_to_image(
|
||||
material_id,
|
||||
input_path,
|
||||
&output_path,
|
||||
&watermark_path,
|
||||
&watermark_config,
|
||||
).await
|
||||
}
|
||||
}
|
||||
WatermarkOperation::DetectAndRemove => {
|
||||
// 先检测,再移除
|
||||
let detection_config: WatermarkDetectionConfig = serde_json::from_value(
|
||||
config.get("detection").unwrap_or(&serde_json::Value::Null).clone()
|
||||
)?;
|
||||
let removal_config: WatermarkRemovalConfig = serde_json::from_value(
|
||||
config.get("removal").unwrap_or(&serde_json::Value::Null).clone()
|
||||
)?;
|
||||
|
||||
// 执行检测
|
||||
let detection_result = if format!("{:?}", material.material_type).to_lowercase().contains("video") {
|
||||
WatermarkDetectionService::detect_watermarks_in_video(
|
||||
material_id,
|
||||
input_path,
|
||||
&detection_config,
|
||||
repository.clone(),
|
||||
).await?
|
||||
} else {
|
||||
WatermarkDetectionService::detect_watermarks_in_image(
|
||||
material_id,
|
||||
input_path,
|
||||
&detection_config,
|
||||
).await?
|
||||
};
|
||||
|
||||
// 保存检测结果
|
||||
Self::save_detection_result(&detection_result, &repository).await?;
|
||||
|
||||
// 如果检测到水印,则执行移除
|
||||
if !detection_result.detections.is_empty() {
|
||||
if format!("{:?}", material.material_type).to_lowercase().contains("video") {
|
||||
WatermarkRemovalService::remove_watermarks_from_video(
|
||||
material_id,
|
||||
input_path,
|
||||
&output_path,
|
||||
&removal_config,
|
||||
repository.clone(),
|
||||
).await
|
||||
} else {
|
||||
WatermarkRemovalService::remove_watermarks_from_image(
|
||||
material_id,
|
||||
input_path,
|
||||
&output_path,
|
||||
&removal_config,
|
||||
).await
|
||||
}
|
||||
} else {
|
||||
Ok(WatermarkProcessingResult {
|
||||
material_id: material_id.to_string(),
|
||||
operation: operation.clone(),
|
||||
success: true,
|
||||
output_path: None,
|
||||
processing_time_ms: detection_result.processing_time_ms,
|
||||
error_message: None,
|
||||
metadata: Some(serde_json::json!({
|
||||
"message": "未检测到水印,无需移除"
|
||||
})),
|
||||
})
|
||||
}
|
||||
}
|
||||
WatermarkOperation::Replace => {
|
||||
// TODO: 实现替换逻辑(检测 + 移除 + 添加)
|
||||
Err(anyhow!("替换操作尚未实现"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// 保存检测结果
|
||||
async fn save_detection_result(
|
||||
result: &crate::data::models::watermark::WatermarkDetectionResult,
|
||||
repository: &MaterialRepository,
|
||||
) -> Result<()> {
|
||||
// TODO: 实现检测结果保存逻辑
|
||||
debug!(
|
||||
material_id = %result.material_id,
|
||||
detection_count = result.detections.len(),
|
||||
"保存检测结果"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取输出目录
|
||||
fn get_output_directory(project_id: &str, operation: &WatermarkOperation) -> String {
|
||||
let operation_name = match operation {
|
||||
WatermarkOperation::Detect => "detection",
|
||||
WatermarkOperation::Remove => "watermark_removed",
|
||||
WatermarkOperation::Add => "watermark_added",
|
||||
WatermarkOperation::DetectAndRemove => "watermark_removed",
|
||||
WatermarkOperation::Replace => "watermark_replaced",
|
||||
};
|
||||
|
||||
format!("projects/{}/output/{}", project_id, operation_name)
|
||||
}
|
||||
|
||||
/// 生成输出文件名
|
||||
fn generate_output_filename(original_filename: &str, operation: &WatermarkOperation) -> String {
|
||||
let operation_suffix = match operation {
|
||||
WatermarkOperation::Detect => return original_filename.to_string(), // 检测不产生文件
|
||||
WatermarkOperation::Remove => "_no_watermark",
|
||||
WatermarkOperation::Add => "_watermarked",
|
||||
WatermarkOperation::DetectAndRemove => "_no_watermark",
|
||||
WatermarkOperation::Replace => "_watermark_replaced",
|
||||
};
|
||||
|
||||
if let Some(dot_index) = original_filename.rfind('.') {
|
||||
let (name, ext) = original_filename.split_at(dot_index);
|
||||
format!("{}{}{}", name, operation_suffix, ext)
|
||||
} else {
|
||||
format!("{}{}", original_filename, operation_suffix)
|
||||
}
|
||||
}
|
||||
|
||||
/// 从配置中获取水印路径
|
||||
fn get_watermark_path_from_config(config: &WatermarkConfig) -> Result<String> {
|
||||
// TODO: 根据水印类型和配置获取实际的水印文件路径
|
||||
// 这里需要与水印模板管理系统集成
|
||||
Ok("watermarks/default.png".to_string())
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,12 @@ pub mod project_template_binding_service;
|
||||
pub mod material_matching_service;
|
||||
pub mod template_matching_result_service;
|
||||
pub mod export_record_service;
|
||||
pub mod watermark_detection_service;
|
||||
pub mod watermark_removal_service;
|
||||
pub mod watermark_addition_service;
|
||||
pub mod batch_watermark_processor;
|
||||
pub mod watermark_template_service;
|
||||
pub mod task_manager;
|
||||
pub mod video_generation_service;
|
||||
pub mod conversation_service;
|
||||
pub mod jianying_export;
|
||||
|
||||
145
apps/desktop/src-tauri/src/business/services/task_manager.rs
Normal file
145
apps/desktop/src-tauri/src/business/services/task_manager.rs
Normal file
@@ -0,0 +1,145 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use anyhow::Result;
|
||||
use tracing::{info, debug};
|
||||
|
||||
use crate::data::models::watermark::{BatchWatermarkTask, BatchTaskStatus, BatchProgress};
|
||||
|
||||
/// 任务状态管理器
|
||||
/// 用于跟踪批量水印处理任务的状态
|
||||
pub struct TaskManager {
|
||||
tasks: Arc<Mutex<HashMap<String, BatchWatermarkTask>>>,
|
||||
}
|
||||
|
||||
impl TaskManager {
|
||||
/// 创建新的任务管理器
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
tasks: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// 添加任务
|
||||
pub fn add_task(&self, task: BatchWatermarkTask) -> Result<()> {
|
||||
let mut tasks = self.tasks.lock().unwrap();
|
||||
debug!(task_id = %task.task_id, "添加任务到管理器");
|
||||
tasks.insert(task.task_id.clone(), task);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取任务状态
|
||||
pub fn get_task(&self, task_id: &str) -> Option<BatchWatermarkTask> {
|
||||
let tasks = self.tasks.lock().unwrap();
|
||||
tasks.get(task_id).cloned()
|
||||
}
|
||||
|
||||
/// 更新任务状态
|
||||
pub fn update_task_status(&self, task_id: &str, status: BatchTaskStatus) -> Result<()> {
|
||||
let mut tasks = self.tasks.lock().unwrap();
|
||||
if let Some(task) = tasks.get_mut(task_id) {
|
||||
task.status = status.clone();
|
||||
task.updated_at = chrono::Utc::now();
|
||||
|
||||
// 如果任务开始运行,设置开始时间
|
||||
if status == BatchTaskStatus::Running && task.started_at.is_none() {
|
||||
task.started_at = Some(chrono::Utc::now());
|
||||
}
|
||||
|
||||
// 如果任务完成,设置完成时间
|
||||
if matches!(status, BatchTaskStatus::Completed | BatchTaskStatus::Failed | BatchTaskStatus::Cancelled) {
|
||||
task.completed_at = Some(chrono::Utc::now());
|
||||
}
|
||||
|
||||
debug!(task_id = %task_id, status = ?status, "更新任务状态");
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow::anyhow!("任务不存在: {}", task_id))
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新任务进度
|
||||
pub fn update_task_progress(&self, task_id: &str, progress: BatchProgress) -> Result<()> {
|
||||
let mut tasks = self.tasks.lock().unwrap();
|
||||
if let Some(task) = tasks.get_mut(task_id) {
|
||||
task.progress = progress;
|
||||
task.updated_at = chrono::Utc::now();
|
||||
debug!(
|
||||
task_id = %task_id,
|
||||
processed = task.progress.processed_items,
|
||||
total = task.progress.total_items,
|
||||
percentage = task.progress.progress_percentage,
|
||||
"更新任务进度"
|
||||
);
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow::anyhow!("任务不存在: {}", task_id))
|
||||
}
|
||||
}
|
||||
|
||||
/// 移除任务
|
||||
pub fn remove_task(&self, task_id: &str) -> Option<BatchWatermarkTask> {
|
||||
let mut tasks = self.tasks.lock().unwrap();
|
||||
let removed = tasks.remove(task_id);
|
||||
if removed.is_some() {
|
||||
debug!(task_id = %task_id, "从管理器中移除任务");
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
/// 获取所有任务
|
||||
pub fn get_all_tasks(&self) -> Vec<BatchWatermarkTask> {
|
||||
let tasks = self.tasks.lock().unwrap();
|
||||
tasks.values().cloned().collect()
|
||||
}
|
||||
|
||||
/// 获取运行中的任务数量
|
||||
pub fn get_running_task_count(&self) -> usize {
|
||||
let tasks = self.tasks.lock().unwrap();
|
||||
tasks.values().filter(|task| task.status == BatchTaskStatus::Running).count()
|
||||
}
|
||||
|
||||
/// 清理已完成的任务(超过指定时间)
|
||||
pub fn cleanup_completed_tasks(&self, max_age_hours: u64) -> usize {
|
||||
let mut tasks = self.tasks.lock().unwrap();
|
||||
let cutoff_time = chrono::Utc::now() - chrono::Duration::hours(max_age_hours as i64);
|
||||
|
||||
let initial_count = tasks.len();
|
||||
tasks.retain(|_, task| {
|
||||
// 保留未完成的任务或最近完成的任务
|
||||
!matches!(task.status, BatchTaskStatus::Completed | BatchTaskStatus::Failed | BatchTaskStatus::Cancelled)
|
||||
|| task.completed_at.map_or(true, |completed| completed > cutoff_time)
|
||||
});
|
||||
|
||||
let removed_count = initial_count - tasks.len();
|
||||
if removed_count > 0 {
|
||||
info!(removed_count = removed_count, "清理已完成的任务");
|
||||
}
|
||||
removed_count
|
||||
}
|
||||
|
||||
/// 取消任务
|
||||
pub fn cancel_task(&self, task_id: &str) -> Result<()> {
|
||||
self.update_task_status(task_id, BatchTaskStatus::Cancelled)
|
||||
}
|
||||
|
||||
/// 暂停任务
|
||||
pub fn pause_task(&self, task_id: &str) -> Result<()> {
|
||||
self.update_task_status(task_id, BatchTaskStatus::Paused)
|
||||
}
|
||||
|
||||
/// 恢复任务
|
||||
pub fn resume_task(&self, task_id: &str) -> Result<()> {
|
||||
self.update_task_status(task_id, BatchTaskStatus::Running)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TaskManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// 全局任务管理器实例
|
||||
lazy_static::lazy_static! {
|
||||
pub static ref TASK_MANAGER: TaskManager = TaskManager::new();
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod draft_parser_tests;
|
||||
pub mod cloud_upload_service_tests;
|
||||
pub mod watermark_tests;
|
||||
|
||||
// 测试工具函数
|
||||
pub mod test_utils {
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
#[cfg(test)]
|
||||
mod watermark_tests {
|
||||
use crate::data::models::watermark::*;
|
||||
use crate::data::repositories::watermark_template_repository::WatermarkTemplateRepository;
|
||||
use crate::infrastructure::database::Database;
|
||||
use std::sync::Arc;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// 创建测试数据库
|
||||
fn create_test_database() -> Arc<Database> {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let db_path = temp_dir.path().join("test.db");
|
||||
Arc::new(Database::new(db_path.to_str().unwrap()).unwrap())
|
||||
}
|
||||
|
||||
/// 创建测试水印模板
|
||||
fn create_test_template() -> WatermarkTemplate {
|
||||
WatermarkTemplate {
|
||||
id: "test_template_1".to_string(),
|
||||
name: "测试水印".to_string(),
|
||||
file_path: "/tmp/test_watermark.png".to_string(),
|
||||
thumbnail_path: Some("/tmp/test_watermark_thumb.jpg".to_string()),
|
||||
category: WatermarkCategory::Logo,
|
||||
watermark_type: WatermarkType::Image,
|
||||
file_size: 1024,
|
||||
width: Some(200),
|
||||
height: Some(100),
|
||||
description: Some("测试用水印模板".to_string()),
|
||||
tags: vec!["test".to_string(), "logo".to_string()],
|
||||
is_active: true,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建测试检测配置
|
||||
fn create_test_detection_config() -> WatermarkDetectionConfig {
|
||||
WatermarkDetectionConfig {
|
||||
similarity_threshold: 0.8,
|
||||
min_watermark_size: (32, 32),
|
||||
max_watermark_size: (512, 512),
|
||||
detection_regions: vec![DetectionRegion::Corners, DetectionRegion::Center],
|
||||
frame_sample_rate: 30,
|
||||
methods: vec![DetectionMethod::TemplateMatching, DetectionMethod::EdgeDetection],
|
||||
template_ids: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建测试移除配置
|
||||
fn create_test_removal_config() -> WatermarkRemovalConfig {
|
||||
WatermarkRemovalConfig {
|
||||
method: RemovalMethod::Blurring,
|
||||
quality_level: QualityLevel::Medium,
|
||||
preserve_aspect_ratio: true,
|
||||
target_regions: None,
|
||||
inpainting_model: None,
|
||||
blur_radius: Some(5.0),
|
||||
crop_margin: Some(10),
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建测试添加配置
|
||||
fn create_test_addition_config() -> WatermarkConfig {
|
||||
WatermarkConfig {
|
||||
watermark_type: WatermarkType::Image,
|
||||
position: WatermarkPosition::BottomRight,
|
||||
opacity: 0.8,
|
||||
scale: 1.0,
|
||||
rotation: 0.0,
|
||||
animation: None,
|
||||
blend_mode: BlendMode::Normal,
|
||||
quality_level: QualityLevel::Medium,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_watermark_template_repository_crud() {
|
||||
let database = create_test_database();
|
||||
let repository = Arc::new(WatermarkTemplateRepository::new(database));
|
||||
let template = create_test_template();
|
||||
|
||||
// 测试创建
|
||||
let result = repository.create(&template);
|
||||
assert!(result.is_ok(), "创建水印模板失败: {:?}", result.err());
|
||||
|
||||
// 测试查询
|
||||
let retrieved = repository.get_by_id(&template.id).unwrap();
|
||||
assert!(retrieved.is_some(), "查询水印模板失败");
|
||||
let retrieved_template = retrieved.unwrap();
|
||||
assert_eq!(retrieved_template.id, template.id);
|
||||
assert_eq!(retrieved_template.name, template.name);
|
||||
|
||||
// 测试更新
|
||||
let mut updated_template = retrieved_template.clone();
|
||||
updated_template.name = "更新后的水印".to_string();
|
||||
updated_template.updated_at = chrono::Utc::now();
|
||||
|
||||
let update_result = repository.update(&updated_template);
|
||||
assert!(update_result.is_ok(), "更新水印模板失败: {:?}", update_result.err());
|
||||
|
||||
// 验证更新
|
||||
let updated_retrieved = repository.get_by_id(&template.id).unwrap().unwrap();
|
||||
assert_eq!(updated_retrieved.name, "更新后的水印");
|
||||
|
||||
// 测试删除
|
||||
let delete_result = repository.delete(&template.id);
|
||||
assert!(delete_result.is_ok(), "删除水印模板失败: {:?}", delete_result.err());
|
||||
|
||||
// 验证删除
|
||||
let deleted_check = repository.get_by_id(&template.id).unwrap();
|
||||
assert!(deleted_check.is_none(), "水印模板应该已被删除");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_watermark_template_search() {
|
||||
let database = create_test_database();
|
||||
let repository = Arc::new(WatermarkTemplateRepository::new(database));
|
||||
|
||||
// 创建多个测试模板
|
||||
let mut template1 = create_test_template();
|
||||
template1.id = "template_1".to_string();
|
||||
template1.name = "Logo水印".to_string();
|
||||
template1.category = WatermarkCategory::Logo;
|
||||
|
||||
let mut template2 = create_test_template();
|
||||
template2.id = "template_2".to_string();
|
||||
template2.name = "版权水印".to_string();
|
||||
template2.category = WatermarkCategory::Copyright;
|
||||
|
||||
let mut template3 = create_test_template();
|
||||
template3.id = "template_3".to_string();
|
||||
template3.name = "签名水印".to_string();
|
||||
template3.category = WatermarkCategory::Signature;
|
||||
template3.watermark_type = WatermarkType::Text;
|
||||
|
||||
// 插入模板
|
||||
repository.create(&template1).unwrap();
|
||||
repository.create(&template2).unwrap();
|
||||
repository.create(&template3).unwrap();
|
||||
|
||||
// 测试按分类查询
|
||||
let logo_templates = repository.get_by_category(&WatermarkCategory::Logo).unwrap();
|
||||
assert_eq!(logo_templates.len(), 1);
|
||||
assert_eq!(logo_templates[0].name, "Logo水印");
|
||||
|
||||
// 测试按类型查询
|
||||
let text_templates = repository.get_by_type(&WatermarkType::Text).unwrap();
|
||||
assert_eq!(text_templates.len(), 1);
|
||||
assert_eq!(text_templates[0].name, "签名水印");
|
||||
|
||||
// 测试搜索
|
||||
let search_results = repository.search("水印").unwrap();
|
||||
assert_eq!(search_results.len(), 3);
|
||||
|
||||
let logo_search = repository.search("Logo").unwrap();
|
||||
assert_eq!(logo_search.len(), 1);
|
||||
assert_eq!(logo_search[0].name, "Logo水印");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_watermark_detection_config_validation() {
|
||||
let config = create_test_detection_config();
|
||||
|
||||
// 验证阈值范围
|
||||
assert!(config.similarity_threshold >= 0.0 && config.similarity_threshold <= 1.0);
|
||||
|
||||
// 验证尺寸设置
|
||||
assert!(config.min_watermark_size.0 > 0);
|
||||
assert!(config.min_watermark_size.1 > 0);
|
||||
assert!(config.max_watermark_size.0 >= config.min_watermark_size.0);
|
||||
assert!(config.max_watermark_size.1 >= config.min_watermark_size.1);
|
||||
|
||||
// 验证采样率
|
||||
assert!(config.frame_sample_rate > 0);
|
||||
|
||||
// 验证检测方法不为空
|
||||
assert!(!config.methods.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_watermark_removal_config_validation() {
|
||||
let config = create_test_removal_config();
|
||||
|
||||
// 验证模糊半径
|
||||
if let Some(blur_radius) = config.blur_radius {
|
||||
assert!(blur_radius > 0.0);
|
||||
}
|
||||
|
||||
// 验证裁剪边距
|
||||
if let Some(crop_margin) = config.crop_margin {
|
||||
assert!(crop_margin > 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_watermark_addition_config_validation() {
|
||||
let config = create_test_addition_config();
|
||||
|
||||
// 验证透明度范围
|
||||
assert!(config.opacity >= 0.0 && config.opacity <= 1.0);
|
||||
|
||||
// 验证缩放比例
|
||||
assert!(config.scale > 0.0);
|
||||
|
||||
// 验证旋转角度范围
|
||||
assert!(config.rotation >= -360.0 && config.rotation <= 360.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_watermark_position_calculation() {
|
||||
let video_width = 1920.0;
|
||||
let video_height = 1080.0;
|
||||
let scale = 1.0;
|
||||
|
||||
// 测试固定位置
|
||||
let positions = vec![
|
||||
WatermarkPosition::TopLeft,
|
||||
WatermarkPosition::TopRight,
|
||||
WatermarkPosition::BottomLeft,
|
||||
WatermarkPosition::BottomRight,
|
||||
WatermarkPosition::Center,
|
||||
];
|
||||
|
||||
for position in positions {
|
||||
// 这里应该调用实际的位置计算函数
|
||||
// 由于函数在服务中是私有的,这里只做概念验证
|
||||
match position {
|
||||
WatermarkPosition::TopLeft => {
|
||||
// 应该返回 (10, 10) 或类似的左上角位置
|
||||
}
|
||||
WatermarkPosition::BottomRight => {
|
||||
// 应该返回接近 (video_width - watermark_width, video_height - watermark_height) 的位置
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_batch_progress_calculation() {
|
||||
let mut progress = BatchProgress {
|
||||
total_items: 10,
|
||||
processed_items: 3,
|
||||
failed_items: 1,
|
||||
current_item: Some("material_4".to_string()),
|
||||
progress_percentage: 0.0,
|
||||
estimated_remaining_ms: None,
|
||||
errors: vec!["Error 1".to_string()],
|
||||
};
|
||||
|
||||
// 计算进度百分比
|
||||
progress.progress_percentage = (progress.processed_items as f32 / progress.total_items as f32) * 100.0;
|
||||
assert_eq!(progress.progress_percentage, 30.0);
|
||||
|
||||
// 验证错误计数
|
||||
assert_eq!(progress.errors.len(), 1);
|
||||
assert_eq!(progress.failed_items, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_watermark_error_types() {
|
||||
use crate::business::errors::watermark_errors::*;
|
||||
|
||||
// 测试错误创建
|
||||
let detection_error = WatermarkError::detection_failed("检测算法失败");
|
||||
assert_eq!(detection_error.error_type(), "detection_failed");
|
||||
assert!(!detection_error.is_retryable());
|
||||
assert!(detection_error.is_system_error());
|
||||
|
||||
let template_error = WatermarkError::template_not_found("template_123");
|
||||
assert_eq!(template_error.error_type(), "template_not_found");
|
||||
assert!(!template_error.is_retryable());
|
||||
assert!(template_error.is_user_error());
|
||||
|
||||
let network_error = WatermarkError::network_error("http://example.com", "连接超时");
|
||||
assert_eq!(network_error.error_type(), "network_error");
|
||||
assert!(network_error.is_retryable());
|
||||
assert!(!network_error.is_user_error());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_severity_levels() {
|
||||
use crate::business::errors::watermark_errors::*;
|
||||
|
||||
let validation_error = WatermarkError::validation_error("opacity", "1.5", "值超出范围");
|
||||
assert_eq!(validation_error.severity(), ErrorSeverity::Warning);
|
||||
|
||||
let template_error = WatermarkError::template_not_found("template_123");
|
||||
assert_eq!(template_error.severity(), ErrorSeverity::Error);
|
||||
|
||||
let internal_error = WatermarkError::internal_error("系统内部错误");
|
||||
assert_eq!(internal_error.severity(), ErrorSeverity::Critical);
|
||||
|
||||
let cancelled_error = WatermarkError::task_cancelled("task_123");
|
||||
assert_eq!(cancelled_error.severity(), ErrorSeverity::Info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_context() {
|
||||
use crate::business::errors::watermark_errors::*;
|
||||
|
||||
let context = ErrorContext::new("watermark_detection")
|
||||
.with_material_id("material_123")
|
||||
.with_template_id("template_456")
|
||||
.with_file_path("/path/to/video.mp4")
|
||||
.with_info("algorithm", "template_matching")
|
||||
.with_info("threshold", "0.8");
|
||||
|
||||
assert_eq!(context.operation, "watermark_detection");
|
||||
assert_eq!(context.material_id, Some("material_123".to_string()));
|
||||
assert_eq!(context.template_id, Some("template_456".to_string()));
|
||||
assert_eq!(context.file_path, Some("/path/to/video.mp4".to_string()));
|
||||
assert_eq!(context.additional_info.get("algorithm"), Some(&"template_matching".to_string()));
|
||||
assert_eq!(context.additional_info.get("threshold"), Some(&"0.8".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_watermark_template_stats() {
|
||||
let database = create_test_database();
|
||||
let repository = Arc::new(WatermarkTemplateRepository::new(database));
|
||||
|
||||
// 创建不同类型的模板
|
||||
let mut template1 = create_test_template();
|
||||
template1.id = "template_1".to_string();
|
||||
template1.category = WatermarkCategory::Logo;
|
||||
template1.watermark_type = WatermarkType::Image;
|
||||
template1.file_size = 1000;
|
||||
|
||||
let mut template2 = create_test_template();
|
||||
template2.id = "template_2".to_string();
|
||||
template2.category = WatermarkCategory::Copyright;
|
||||
template2.watermark_type = WatermarkType::Text;
|
||||
template2.file_size = 500;
|
||||
|
||||
repository.create(&template1).unwrap();
|
||||
repository.create(&template2).unwrap();
|
||||
|
||||
// 获取统计信息
|
||||
let stats = repository.get_stats().unwrap();
|
||||
|
||||
assert_eq!(stats["total_templates"], 2);
|
||||
assert_eq!(stats["total_size"], 1500);
|
||||
|
||||
// 验证分类统计
|
||||
let by_category = &stats["by_category"];
|
||||
assert!(by_category.get("\"Logo\"").is_some());
|
||||
assert!(by_category.get("\"Copyright\"").is_some());
|
||||
|
||||
// 验证类型统计
|
||||
let by_type = &stats["by_type"];
|
||||
assert!(by_type.get("\"Image\"").is_some());
|
||||
assert!(by_type.get("\"Text\"").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_watermark_template_name_validation() {
|
||||
let database = create_test_database();
|
||||
let repository = Arc::new(WatermarkTemplateRepository::new(database));
|
||||
let template = create_test_template();
|
||||
|
||||
// 创建模板
|
||||
repository.create(&template).unwrap();
|
||||
|
||||
// 测试名称冲突检查
|
||||
let name_exists = repository.name_exists(&template.name, None).unwrap();
|
||||
assert!(name_exists, "应该检测到名称已存在");
|
||||
|
||||
// 测试排除自身的名称检查
|
||||
let name_exists_exclude_self = repository.name_exists(&template.name, Some(&template.id)).unwrap();
|
||||
assert!(!name_exists_exclude_self, "排除自身时不应该检测到名称冲突");
|
||||
|
||||
// 测试不存在的名称
|
||||
let new_name_exists = repository.name_exists("不存在的名称", None).unwrap();
|
||||
assert!(!new_name_exists, "不存在的名称不应该被检测为已存在");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,713 @@
|
||||
use anyhow::{Result, anyhow};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use tracing::{info, error, debug};
|
||||
|
||||
use crate::data::models::watermark::{
|
||||
WatermarkConfig, WatermarkType, WatermarkPosition, WatermarkAnimation,
|
||||
AnimationType, BlendMode, QualityLevel, WatermarkProcessingResult,
|
||||
WatermarkOperation, DynamicPositionRule, Corner
|
||||
};
|
||||
use crate::data::repositories::material_repository::MaterialRepository;
|
||||
use crate::infrastructure::ffmpeg_watermark::FFmpegWatermark;
|
||||
use crate::infrastructure::monitoring::PERFORMANCE_MONITOR;
|
||||
|
||||
/// 水印添加服务
|
||||
/// 遵循 Tauri 开发规范的业务逻辑层设计
|
||||
pub struct WatermarkAdditionService;
|
||||
|
||||
impl WatermarkAdditionService {
|
||||
/// 为视频添加水印
|
||||
pub async fn add_watermark_to_video(
|
||||
material_id: &str,
|
||||
input_path: &str,
|
||||
output_path: &str,
|
||||
watermark_path: &str,
|
||||
config: &WatermarkConfig,
|
||||
repository: Arc<MaterialRepository>,
|
||||
) -> Result<WatermarkProcessingResult> {
|
||||
let timer = PERFORMANCE_MONITOR.start_operation("watermark_addition_video");
|
||||
let start_time = Instant::now();
|
||||
|
||||
info!(
|
||||
material_id = %material_id,
|
||||
input_path = %input_path,
|
||||
output_path = %output_path,
|
||||
watermark_path = %watermark_path,
|
||||
watermark_type = ?config.watermark_type,
|
||||
"开始为视频添加水印"
|
||||
);
|
||||
|
||||
// 验证输入文件存在
|
||||
if !Path::new(input_path).exists() {
|
||||
return Err(anyhow!("输入视频文件不存在: {}", input_path));
|
||||
}
|
||||
|
||||
if !Path::new(watermark_path).exists() {
|
||||
return Err(anyhow!("水印文件不存在: {}", watermark_path));
|
||||
}
|
||||
|
||||
// 确保输出目录存在
|
||||
if let Some(parent) = Path::new(output_path).parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let result = match config.watermark_type {
|
||||
WatermarkType::Image => {
|
||||
Self::add_image_watermark_to_video(
|
||||
input_path,
|
||||
output_path,
|
||||
watermark_path,
|
||||
config,
|
||||
).await
|
||||
}
|
||||
WatermarkType::Text => {
|
||||
Self::add_text_watermark_to_video(
|
||||
input_path,
|
||||
output_path,
|
||||
watermark_path,
|
||||
config,
|
||||
).await
|
||||
}
|
||||
WatermarkType::Vector => {
|
||||
Self::add_vector_watermark_to_video(
|
||||
input_path,
|
||||
output_path,
|
||||
watermark_path,
|
||||
config,
|
||||
).await
|
||||
}
|
||||
WatermarkType::Animated => {
|
||||
Self::add_animated_watermark_to_video(
|
||||
input_path,
|
||||
output_path,
|
||||
watermark_path,
|
||||
config,
|
||||
).await
|
||||
}
|
||||
};
|
||||
|
||||
let processing_time = start_time.elapsed().as_millis() as u64;
|
||||
|
||||
let processing_result = match result {
|
||||
Ok(_) => {
|
||||
info!(
|
||||
material_id = %material_id,
|
||||
processing_time_ms = processing_time,
|
||||
"视频水印添加成功"
|
||||
);
|
||||
|
||||
WatermarkProcessingResult {
|
||||
material_id: material_id.to_string(),
|
||||
operation: WatermarkOperation::Add,
|
||||
success: true,
|
||||
output_path: Some(output_path.to_string()),
|
||||
processing_time_ms: processing_time,
|
||||
error_message: None,
|
||||
metadata: None,
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
material_id = %material_id,
|
||||
error = %e,
|
||||
processing_time_ms = processing_time,
|
||||
"视频水印添加失败"
|
||||
);
|
||||
|
||||
WatermarkProcessingResult {
|
||||
material_id: material_id.to_string(),
|
||||
operation: WatermarkOperation::Add,
|
||||
success: false,
|
||||
output_path: None,
|
||||
processing_time_ms: processing_time,
|
||||
error_message: Some(e.to_string()),
|
||||
metadata: None,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(processing_result)
|
||||
}
|
||||
|
||||
/// 为图片添加水印
|
||||
pub async fn add_watermark_to_image(
|
||||
material_id: &str,
|
||||
input_path: &str,
|
||||
output_path: &str,
|
||||
watermark_path: &str,
|
||||
config: &WatermarkConfig,
|
||||
) -> Result<WatermarkProcessingResult> {
|
||||
let timer = PERFORMANCE_MONITOR.start_operation("watermark_addition_image");
|
||||
let start_time = Instant::now();
|
||||
|
||||
info!(
|
||||
material_id = %material_id,
|
||||
input_path = %input_path,
|
||||
output_path = %output_path,
|
||||
watermark_path = %watermark_path,
|
||||
watermark_type = ?config.watermark_type,
|
||||
"开始为图片添加水印"
|
||||
);
|
||||
|
||||
// 验证输入文件存在
|
||||
if !Path::new(input_path).exists() {
|
||||
return Err(anyhow!("输入图片文件不存在: {}", input_path));
|
||||
}
|
||||
|
||||
if !Path::new(watermark_path).exists() {
|
||||
return Err(anyhow!("水印文件不存在: {}", watermark_path));
|
||||
}
|
||||
|
||||
// 确保输出目录存在
|
||||
if let Some(parent) = Path::new(output_path).parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let result = match config.watermark_type {
|
||||
WatermarkType::Image => {
|
||||
Self::add_image_watermark_to_image(
|
||||
input_path,
|
||||
output_path,
|
||||
watermark_path,
|
||||
config,
|
||||
).await
|
||||
}
|
||||
WatermarkType::Text => {
|
||||
Self::add_text_watermark_to_image(
|
||||
input_path,
|
||||
output_path,
|
||||
watermark_path,
|
||||
config,
|
||||
).await
|
||||
}
|
||||
WatermarkType::Vector => {
|
||||
Self::add_vector_watermark_to_image(
|
||||
input_path,
|
||||
output_path,
|
||||
watermark_path,
|
||||
config,
|
||||
).await
|
||||
}
|
||||
WatermarkType::Animated => {
|
||||
// 动态水印不适用于静态图片
|
||||
Err(anyhow!("动态水印不能应用于静态图片"))
|
||||
}
|
||||
};
|
||||
|
||||
let processing_time = start_time.elapsed().as_millis() as u64;
|
||||
|
||||
let processing_result = match result {
|
||||
Ok(_) => {
|
||||
info!(
|
||||
material_id = %material_id,
|
||||
processing_time_ms = processing_time,
|
||||
"图片水印添加成功"
|
||||
);
|
||||
|
||||
WatermarkProcessingResult {
|
||||
material_id: material_id.to_string(),
|
||||
operation: WatermarkOperation::Add,
|
||||
success: true,
|
||||
output_path: Some(output_path.to_string()),
|
||||
processing_time_ms: processing_time,
|
||||
error_message: None,
|
||||
metadata: None,
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
material_id = %material_id,
|
||||
error = %e,
|
||||
processing_time_ms = processing_time,
|
||||
"图片水印添加失败"
|
||||
);
|
||||
|
||||
WatermarkProcessingResult {
|
||||
material_id: material_id.to_string(),
|
||||
operation: WatermarkOperation::Add,
|
||||
success: false,
|
||||
output_path: None,
|
||||
processing_time_ms: processing_time,
|
||||
error_message: Some(e.to_string()),
|
||||
metadata: None,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(processing_result)
|
||||
}
|
||||
|
||||
/// 为视频添加图片水印
|
||||
async fn add_image_watermark_to_video(
|
||||
input_path: &str,
|
||||
output_path: &str,
|
||||
watermark_path: &str,
|
||||
config: &WatermarkConfig,
|
||||
) -> Result<()> {
|
||||
debug!("为视频添加图片水印");
|
||||
|
||||
// 获取视频信息
|
||||
let video_info = FFmpegWatermark::get_video_info(input_path)?;
|
||||
let video_width = video_info.width.unwrap_or(1920) as f32;
|
||||
let video_height = video_info.height.unwrap_or(1080) as f32;
|
||||
|
||||
// 计算水印位置
|
||||
let position = Self::calculate_watermark_position(
|
||||
&config.position,
|
||||
video_width,
|
||||
video_height,
|
||||
config.scale,
|
||||
);
|
||||
|
||||
// 构建overlay滤镜
|
||||
let overlay_filter = Self::build_overlay_filter(
|
||||
&position,
|
||||
config.opacity,
|
||||
config.scale,
|
||||
config.rotation,
|
||||
&config.blend_mode,
|
||||
config.animation.as_ref(),
|
||||
);
|
||||
|
||||
let quality_args = Self::get_quality_args(&config.quality_level);
|
||||
|
||||
let mut args = vec![
|
||||
"-hide_banner",
|
||||
"-loglevel", "error",
|
||||
"-i", input_path,
|
||||
"-i", watermark_path,
|
||||
"-filter_complex", &overlay_filter,
|
||||
];
|
||||
|
||||
args.extend_from_slice(&quality_args);
|
||||
args.extend_from_slice(&["-c:a", "copy", "-y", output_path]);
|
||||
|
||||
FFmpegWatermark::execute_command(&args)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 为视频添加文字水印
|
||||
async fn add_text_watermark_to_video(
|
||||
input_path: &str,
|
||||
output_path: &str,
|
||||
text_content: &str,
|
||||
config: &WatermarkConfig,
|
||||
) -> Result<()> {
|
||||
debug!("为视频添加文字水印");
|
||||
|
||||
// 获取视频信息
|
||||
let video_info = FFmpegWatermark::get_video_info(input_path)?;
|
||||
let video_width = video_info.width.unwrap_or(1920) as f32;
|
||||
let video_height = video_info.height.unwrap_or(1080) as f32;
|
||||
|
||||
// 计算文字位置
|
||||
let position = Self::calculate_watermark_position(
|
||||
&config.position,
|
||||
video_width,
|
||||
video_height,
|
||||
config.scale,
|
||||
);
|
||||
|
||||
// 构建文字滤镜
|
||||
let text_filter = Self::build_text_filter(
|
||||
text_content,
|
||||
&position,
|
||||
config.opacity,
|
||||
config.scale,
|
||||
config.rotation,
|
||||
);
|
||||
|
||||
let quality_args = Self::get_quality_args(&config.quality_level);
|
||||
|
||||
let mut args = vec![
|
||||
"-hide_banner",
|
||||
"-loglevel", "error",
|
||||
"-i", input_path,
|
||||
"-vf", &text_filter,
|
||||
];
|
||||
|
||||
args.extend_from_slice(&quality_args);
|
||||
args.extend_from_slice(&["-c:a", "copy", "-y", output_path]);
|
||||
|
||||
FFmpegWatermark::execute_command(&args)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 为视频添加矢量水印
|
||||
async fn add_vector_watermark_to_video(
|
||||
input_path: &str,
|
||||
output_path: &str,
|
||||
watermark_path: &str,
|
||||
config: &WatermarkConfig,
|
||||
) -> Result<()> {
|
||||
debug!("为视频添加矢量水印");
|
||||
|
||||
// SVG需要先转换为PNG
|
||||
let temp_png = Self::convert_svg_to_png(watermark_path, config.scale).await?;
|
||||
|
||||
let result = Self::add_image_watermark_to_video(
|
||||
input_path,
|
||||
output_path,
|
||||
&temp_png,
|
||||
config,
|
||||
).await;
|
||||
|
||||
// 清理临时文件
|
||||
let _ = std::fs::remove_file(&temp_png);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// 为视频添加动态水印
|
||||
async fn add_animated_watermark_to_video(
|
||||
input_path: &str,
|
||||
output_path: &str,
|
||||
watermark_path: &str,
|
||||
config: &WatermarkConfig,
|
||||
) -> Result<()> {
|
||||
debug!("为视频添加动态水印");
|
||||
|
||||
// 获取视频信息
|
||||
let video_info = FFmpegWatermark::get_video_info(input_path)?;
|
||||
let video_width = video_info.width.unwrap_or(1920) as f32;
|
||||
let video_height = video_info.height.unwrap_or(1080) as f32;
|
||||
|
||||
// 计算水印位置
|
||||
let position = Self::calculate_watermark_position(
|
||||
&config.position,
|
||||
video_width,
|
||||
video_height,
|
||||
config.scale,
|
||||
);
|
||||
|
||||
// 构建动态overlay滤镜
|
||||
let overlay_filter = Self::build_animated_overlay_filter(
|
||||
&position,
|
||||
config.opacity,
|
||||
config.scale,
|
||||
config.rotation,
|
||||
&config.blend_mode,
|
||||
config.animation.as_ref(),
|
||||
);
|
||||
|
||||
let quality_args = Self::get_quality_args(&config.quality_level);
|
||||
|
||||
let mut args = vec![
|
||||
"-hide_banner",
|
||||
"-loglevel", "error",
|
||||
"-i", input_path,
|
||||
"-i", watermark_path,
|
||||
"-filter_complex", &overlay_filter,
|
||||
];
|
||||
|
||||
args.extend_from_slice(&quality_args);
|
||||
args.extend_from_slice(&["-c:a", "copy", "-y", output_path]);
|
||||
|
||||
FFmpegWatermark::execute_command(&args)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 为图片添加图片水印
|
||||
async fn add_image_watermark_to_image(
|
||||
input_path: &str,
|
||||
output_path: &str,
|
||||
watermark_path: &str,
|
||||
config: &WatermarkConfig,
|
||||
) -> Result<()> {
|
||||
debug!("为图片添加图片水印");
|
||||
|
||||
// TODO: 获取图片尺寸
|
||||
let image_width = 1920.0; // 临时值
|
||||
let image_height = 1080.0; // 临时值
|
||||
|
||||
// 计算水印位置
|
||||
let position = Self::calculate_watermark_position(
|
||||
&config.position,
|
||||
image_width,
|
||||
image_height,
|
||||
config.scale,
|
||||
);
|
||||
|
||||
// 构建overlay滤镜
|
||||
let overlay_filter = format!(
|
||||
"overlay={}:{}:alpha={}",
|
||||
position.0,
|
||||
position.1,
|
||||
config.opacity
|
||||
);
|
||||
|
||||
let args = vec![
|
||||
"-hide_banner",
|
||||
"-loglevel", "error",
|
||||
"-i", input_path,
|
||||
"-i", watermark_path,
|
||||
"-filter_complex", &overlay_filter,
|
||||
"-y", output_path,
|
||||
];
|
||||
|
||||
FFmpegWatermark::execute_command(&args)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 为图片添加文字水印
|
||||
async fn add_text_watermark_to_image(
|
||||
input_path: &str,
|
||||
output_path: &str,
|
||||
text_content: &str,
|
||||
config: &WatermarkConfig,
|
||||
) -> Result<()> {
|
||||
debug!("为图片添加文字水印");
|
||||
|
||||
// TODO: 获取图片尺寸
|
||||
let image_width = 1920.0; // 临时值
|
||||
let image_height = 1080.0; // 临时值
|
||||
|
||||
// 计算文字位置
|
||||
let position = Self::calculate_watermark_position(
|
||||
&config.position,
|
||||
image_width,
|
||||
image_height,
|
||||
config.scale,
|
||||
);
|
||||
|
||||
// 构建文字滤镜
|
||||
let text_filter = Self::build_text_filter(
|
||||
text_content,
|
||||
&position,
|
||||
config.opacity,
|
||||
config.scale,
|
||||
config.rotation,
|
||||
);
|
||||
|
||||
let args = vec![
|
||||
"-hide_banner",
|
||||
"-loglevel", "error",
|
||||
"-i", input_path,
|
||||
"-vf", &text_filter,
|
||||
"-y", output_path,
|
||||
];
|
||||
|
||||
FFmpegWatermark::execute_command(&args)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 为图片添加矢量水印
|
||||
async fn add_vector_watermark_to_image(
|
||||
input_path: &str,
|
||||
output_path: &str,
|
||||
watermark_path: &str,
|
||||
config: &WatermarkConfig,
|
||||
) -> Result<()> {
|
||||
debug!("为图片添加矢量水印");
|
||||
|
||||
// SVG需要先转换为PNG
|
||||
let temp_png = Self::convert_svg_to_png(watermark_path, config.scale).await?;
|
||||
|
||||
let result = Self::add_image_watermark_to_image(
|
||||
input_path,
|
||||
output_path,
|
||||
&temp_png,
|
||||
config,
|
||||
).await;
|
||||
|
||||
// 清理临时文件
|
||||
let _ = std::fs::remove_file(&temp_png);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// 计算水印位置
|
||||
fn calculate_watermark_position(
|
||||
position: &WatermarkPosition,
|
||||
video_width: f32,
|
||||
video_height: f32,
|
||||
scale: f32,
|
||||
) -> (f32, f32) {
|
||||
let watermark_width = 200.0 * scale; // 假设水印宽度
|
||||
let watermark_height = 100.0 * scale; // 假设水印高度
|
||||
|
||||
match position {
|
||||
WatermarkPosition::TopLeft => (10.0, 10.0),
|
||||
WatermarkPosition::TopCenter => ((video_width - watermark_width) / 2.0, 10.0),
|
||||
WatermarkPosition::TopRight => (video_width - watermark_width - 10.0, 10.0),
|
||||
WatermarkPosition::MiddleLeft => (10.0, (video_height - watermark_height) / 2.0),
|
||||
WatermarkPosition::Center => (
|
||||
(video_width - watermark_width) / 2.0,
|
||||
(video_height - watermark_height) / 2.0,
|
||||
),
|
||||
WatermarkPosition::MiddleRight => (
|
||||
video_width - watermark_width - 10.0,
|
||||
(video_height - watermark_height) / 2.0,
|
||||
),
|
||||
WatermarkPosition::BottomLeft => (10.0, video_height - watermark_height - 10.0),
|
||||
WatermarkPosition::BottomCenter => (
|
||||
(video_width - watermark_width) / 2.0,
|
||||
video_height - watermark_height - 10.0,
|
||||
),
|
||||
WatermarkPosition::BottomRight => (
|
||||
video_width - watermark_width - 10.0,
|
||||
video_height - watermark_height - 10.0,
|
||||
),
|
||||
WatermarkPosition::Custom { x, y } => (
|
||||
x * video_width,
|
||||
y * video_height,
|
||||
),
|
||||
WatermarkPosition::Dynamic(rule) => {
|
||||
Self::calculate_dynamic_position(rule, video_width, video_height, scale)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 计算动态位置
|
||||
fn calculate_dynamic_position(
|
||||
rule: &DynamicPositionRule,
|
||||
video_width: f32,
|
||||
video_height: f32,
|
||||
scale: f32,
|
||||
) -> (f32, f32) {
|
||||
// TODO: 实现动态位置计算逻辑
|
||||
// 这里需要集成人脸检测、文字检测等功能
|
||||
|
||||
// 临时实现:根据角落偏好选择位置
|
||||
if let Some(corner) = rule.corner_preference.first() {
|
||||
let margin = rule.min_distance_from_edge as f32;
|
||||
let watermark_width = 200.0 * scale;
|
||||
let watermark_height = 100.0 * scale;
|
||||
|
||||
match corner {
|
||||
Corner::TopLeft => (margin, margin),
|
||||
Corner::TopRight => (video_width - watermark_width - margin, margin),
|
||||
Corner::BottomLeft => (margin, video_height - watermark_height - margin),
|
||||
Corner::BottomRight => (
|
||||
video_width - watermark_width - margin,
|
||||
video_height - watermark_height - margin,
|
||||
),
|
||||
}
|
||||
} else {
|
||||
// 默认右下角
|
||||
(video_width - 210.0, video_height - 110.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建overlay滤镜
|
||||
fn build_overlay_filter(
|
||||
position: &(f32, f32),
|
||||
opacity: f32,
|
||||
scale: f32,
|
||||
rotation: f32,
|
||||
blend_mode: &BlendMode,
|
||||
animation: Option<&WatermarkAnimation>,
|
||||
) -> String {
|
||||
let mut filter_parts = Vec::new();
|
||||
|
||||
// 缩放
|
||||
if scale != 1.0 {
|
||||
filter_parts.push(format!("[1:v]scale=iw*{}:ih*{}", scale, scale));
|
||||
}
|
||||
|
||||
// 旋转
|
||||
if rotation != 0.0 {
|
||||
filter_parts.push(format!("rotate={}*PI/180", rotation));
|
||||
}
|
||||
|
||||
// 透明度
|
||||
if opacity != 1.0 {
|
||||
filter_parts.push(format!("format=rgba,colorchannelmixer=aa={}", opacity));
|
||||
}
|
||||
|
||||
let watermark_filter = if filter_parts.is_empty() {
|
||||
"[1:v]".to_string()
|
||||
} else {
|
||||
format!("[1:v]{}", filter_parts.join(","))
|
||||
};
|
||||
|
||||
// overlay位置
|
||||
let overlay_expr = if let Some(anim) = animation {
|
||||
Self::build_animated_position_expression(position, anim)
|
||||
} else {
|
||||
format!("{}:{}", position.0, position.1)
|
||||
};
|
||||
|
||||
format!("{}[wm];[0:v][wm]overlay={}", watermark_filter, overlay_expr)
|
||||
}
|
||||
|
||||
/// 构建动态overlay滤镜
|
||||
fn build_animated_overlay_filter(
|
||||
position: &(f32, f32),
|
||||
opacity: f32,
|
||||
scale: f32,
|
||||
rotation: f32,
|
||||
blend_mode: &BlendMode,
|
||||
animation: Option<&WatermarkAnimation>,
|
||||
) -> String {
|
||||
// 与普通overlay类似,但添加动画表达式
|
||||
Self::build_overlay_filter(position, opacity, scale, rotation, blend_mode, animation)
|
||||
}
|
||||
|
||||
/// 构建文字滤镜
|
||||
fn build_text_filter(
|
||||
text: &str,
|
||||
position: &(f32, f32),
|
||||
opacity: f32,
|
||||
scale: f32,
|
||||
rotation: f32,
|
||||
) -> String {
|
||||
let font_size = (24.0 * scale) as u32;
|
||||
let alpha = (opacity * 255.0) as u32;
|
||||
|
||||
format!(
|
||||
"drawtext=text='{}':x={}:y={}:fontsize={}:fontcolor=white@{}",
|
||||
text.replace("'", "\\'"),
|
||||
position.0,
|
||||
position.1,
|
||||
font_size,
|
||||
alpha
|
||||
)
|
||||
}
|
||||
|
||||
/// 构建动画位置表达式
|
||||
fn build_animated_position_expression(
|
||||
base_position: &(f32, f32),
|
||||
animation: &WatermarkAnimation,
|
||||
) -> String {
|
||||
match animation.animation_type {
|
||||
AnimationType::FadeIn => {
|
||||
format!("{}:{}:alpha='if(lt(t,{}),t/{},1)'",
|
||||
base_position.0,
|
||||
base_position.1,
|
||||
animation.duration_ms as f32 / 1000.0,
|
||||
animation.duration_ms as f32 / 1000.0
|
||||
)
|
||||
}
|
||||
AnimationType::SlideIn => {
|
||||
format!("'{}+100*max(0,1-t/{})':{}",
|
||||
base_position.0,
|
||||
animation.duration_ms as f32 / 1000.0,
|
||||
base_position.1
|
||||
)
|
||||
}
|
||||
_ => format!("{}:{}", base_position.0, base_position.1),
|
||||
}
|
||||
}
|
||||
|
||||
/// 转换SVG为PNG
|
||||
async fn convert_svg_to_png(svg_path: &str, scale: f32) -> Result<String> {
|
||||
// TODO: 实现SVG到PNG的转换
|
||||
// 可以使用librsvg或其他SVG渲染库
|
||||
|
||||
// 临时实现:直接返回SVG路径(FFmpeg可能支持SVG)
|
||||
Ok(svg_path.to_string())
|
||||
}
|
||||
|
||||
/// 获取质量参数
|
||||
fn get_quality_args(quality_level: &QualityLevel) -> Vec<&'static str> {
|
||||
match quality_level {
|
||||
QualityLevel::Low => vec!["-c:v", "libx264", "-preset", "ultrafast", "-crf", "28"],
|
||||
QualityLevel::Medium => vec!["-c:v", "libx264", "-preset", "fast", "-crf", "23"],
|
||||
QualityLevel::High => vec!["-c:v", "libx264", "-preset", "slow", "-crf", "18"],
|
||||
QualityLevel::Lossless => vec!["-c:v", "libx264", "-preset", "veryslow", "-crf", "0"],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
use anyhow::{Result, anyhow};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use tracing::{info, warn, debug};
|
||||
|
||||
use crate::data::models::watermark::{
|
||||
WatermarkDetectionResult, WatermarkDetection, WatermarkDetectionConfig,
|
||||
DetectionMethod, BoundingBox, WatermarkType
|
||||
};
|
||||
use crate::data::repositories::material_repository::MaterialRepository;
|
||||
use crate::infrastructure::ffmpeg_watermark::FFmpegWatermark;
|
||||
use crate::infrastructure::monitoring::PERFORMANCE_MONITOR;
|
||||
|
||||
/// 水印检测服务
|
||||
/// 遵循 Tauri 开发规范的业务逻辑层设计
|
||||
pub struct WatermarkDetectionService;
|
||||
|
||||
impl WatermarkDetectionService {
|
||||
/// 检测视频中的水印
|
||||
pub async fn detect_watermarks_in_video(
|
||||
material_id: &str,
|
||||
video_path: &str,
|
||||
config: &WatermarkDetectionConfig,
|
||||
repository: Arc<MaterialRepository>,
|
||||
) -> Result<WatermarkDetectionResult> {
|
||||
let timer = PERFORMANCE_MONITOR.start_operation("watermark_detection");
|
||||
let start_time = Instant::now();
|
||||
|
||||
info!(
|
||||
material_id = %material_id,
|
||||
video_path = %video_path,
|
||||
"开始检测视频水印"
|
||||
);
|
||||
|
||||
// 验证文件存在
|
||||
if !Path::new(video_path).exists() {
|
||||
return Err(anyhow!("视频文件不存在: {}", video_path));
|
||||
}
|
||||
|
||||
// 获取视频信息
|
||||
let video_info = FFmpegWatermark::get_video_info(video_path)?;
|
||||
let frame_count = video_info.frame_count.unwrap_or(0);
|
||||
let duration = video_info.duration;
|
||||
|
||||
debug!(
|
||||
material_id = %material_id,
|
||||
frame_count = frame_count,
|
||||
duration = duration,
|
||||
"视频信息获取完成"
|
||||
);
|
||||
|
||||
let mut all_detections = Vec::new();
|
||||
let mut total_confidence = 0.0;
|
||||
let mut detection_count = 0;
|
||||
|
||||
// 根据配置的采样率提取关键帧进行检测
|
||||
let sample_interval = config.frame_sample_rate.max(1);
|
||||
let sample_frames = Self::calculate_sample_frames(frame_count, sample_interval, duration);
|
||||
|
||||
for (frame_index, timestamp) in sample_frames.iter().enumerate() {
|
||||
debug!(
|
||||
material_id = %material_id,
|
||||
frame_index = frame_index,
|
||||
timestamp = timestamp,
|
||||
"开始检测帧"
|
||||
);
|
||||
|
||||
// 提取帧图像
|
||||
let frame_path = Self::extract_frame(video_path, *timestamp, material_id, frame_index)?;
|
||||
|
||||
// 对每个检测方法进行检测
|
||||
for method in &config.methods {
|
||||
match Self::detect_watermarks_in_frame(
|
||||
&frame_path,
|
||||
method,
|
||||
config,
|
||||
).await {
|
||||
Ok(detections) => {
|
||||
for detection in detections {
|
||||
all_detections.push(detection.clone());
|
||||
total_confidence += detection.confidence;
|
||||
detection_count += 1;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
material_id = %material_id,
|
||||
method = ?method,
|
||||
frame_index = frame_index,
|
||||
error = %e,
|
||||
"帧检测失败"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 清理临时帧文件
|
||||
let _ = std::fs::remove_file(&frame_path);
|
||||
}
|
||||
|
||||
// 合并相似的检测结果
|
||||
let merged_detections = Self::merge_similar_detections(all_detections, 0.7);
|
||||
|
||||
// 计算平均置信度
|
||||
let average_confidence = if detection_count > 0 {
|
||||
total_confidence / detection_count as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let processing_time = start_time.elapsed().as_millis() as u64;
|
||||
|
||||
let result = WatermarkDetectionResult {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
material_id: material_id.to_string(),
|
||||
detection_method: if config.methods.len() == 1 {
|
||||
config.methods[0].clone()
|
||||
} else {
|
||||
DetectionMethod::Combined
|
||||
},
|
||||
detections: merged_detections,
|
||||
confidence_score: average_confidence,
|
||||
processing_time_ms: processing_time,
|
||||
created_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
info!(
|
||||
material_id = %material_id,
|
||||
detection_count = result.detections.len(),
|
||||
confidence = result.confidence_score,
|
||||
processing_time_ms = processing_time,
|
||||
"水印检测完成"
|
||||
);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 检测图片中的水印
|
||||
pub async fn detect_watermarks_in_image(
|
||||
material_id: &str,
|
||||
image_path: &str,
|
||||
config: &WatermarkDetectionConfig,
|
||||
) -> Result<WatermarkDetectionResult> {
|
||||
let timer = PERFORMANCE_MONITOR.start_operation("watermark_detection_image");
|
||||
let start_time = Instant::now();
|
||||
|
||||
info!(
|
||||
material_id = %material_id,
|
||||
image_path = %image_path,
|
||||
"开始检测图片水印"
|
||||
);
|
||||
|
||||
// 验证文件存在
|
||||
if !Path::new(image_path).exists() {
|
||||
return Err(anyhow!("图片文件不存在: {}", image_path));
|
||||
}
|
||||
|
||||
let mut all_detections = Vec::new();
|
||||
|
||||
// 对每个检测方法进行检测
|
||||
for method in &config.methods {
|
||||
match Self::detect_watermarks_in_frame(
|
||||
image_path,
|
||||
method,
|
||||
config,
|
||||
).await {
|
||||
Ok(detections) => {
|
||||
all_detections.extend(detections);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
material_id = %material_id,
|
||||
method = ?method,
|
||||
error = %e,
|
||||
"图片检测失败"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 合并相似的检测结果
|
||||
let merged_detections = Self::merge_similar_detections(all_detections, 0.7);
|
||||
|
||||
// 计算平均置信度
|
||||
let average_confidence = if !merged_detections.is_empty() {
|
||||
merged_detections.iter().map(|d| d.confidence).sum::<f64>() / merged_detections.len() as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let processing_time = start_time.elapsed().as_millis() as u64;
|
||||
|
||||
let result = WatermarkDetectionResult {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
material_id: material_id.to_string(),
|
||||
detection_method: if config.methods.len() == 1 {
|
||||
config.methods[0].clone()
|
||||
} else {
|
||||
DetectionMethod::Combined
|
||||
},
|
||||
detections: merged_detections,
|
||||
confidence_score: average_confidence,
|
||||
processing_time_ms: processing_time,
|
||||
created_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
info!(
|
||||
material_id = %material_id,
|
||||
detection_count = result.detections.len(),
|
||||
confidence = result.confidence_score,
|
||||
processing_time_ms = processing_time,
|
||||
"图片水印检测完成"
|
||||
);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 计算采样帧
|
||||
fn calculate_sample_frames(frame_count: u32, sample_interval: u32, duration: f64) -> Vec<f64> {
|
||||
let mut sample_frames = Vec::new();
|
||||
|
||||
if frame_count == 0 || duration <= 0.0 {
|
||||
return sample_frames;
|
||||
}
|
||||
|
||||
let fps = frame_count as f64 / duration;
|
||||
let total_samples = (frame_count / sample_interval).max(1);
|
||||
|
||||
for i in 0..total_samples {
|
||||
let frame_index = i * sample_interval;
|
||||
let timestamp = frame_index as f64 / fps;
|
||||
if timestamp < duration {
|
||||
sample_frames.push(timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
// 确保至少有一帧
|
||||
if sample_frames.is_empty() {
|
||||
sample_frames.push(duration / 2.0); // 取中间帧
|
||||
}
|
||||
|
||||
sample_frames
|
||||
}
|
||||
|
||||
/// 提取视频帧
|
||||
fn extract_frame(
|
||||
video_path: &str,
|
||||
timestamp: f64,
|
||||
material_id: &str,
|
||||
frame_index: usize,
|
||||
) -> Result<String> {
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let frame_filename = format!("watermark_detection_{}_{}.jpg", material_id, frame_index);
|
||||
let frame_path = temp_dir.join(frame_filename);
|
||||
let frame_path_str = frame_path.to_string_lossy().to_string();
|
||||
|
||||
// 使用FFmpeg提取帧
|
||||
FFmpegWatermark::extract_frame_at_timestamp(
|
||||
video_path,
|
||||
timestamp,
|
||||
&frame_path_str,
|
||||
1920, // 使用高分辨率以提高检测精度
|
||||
1080,
|
||||
)?;
|
||||
|
||||
Ok(frame_path_str)
|
||||
}
|
||||
|
||||
/// 在单帧中检测水印
|
||||
async fn detect_watermarks_in_frame(
|
||||
image_path: &str,
|
||||
method: &DetectionMethod,
|
||||
config: &WatermarkDetectionConfig,
|
||||
) -> Result<Vec<WatermarkDetection>> {
|
||||
match method {
|
||||
DetectionMethod::TemplateMatching => {
|
||||
Self::template_matching_detection(image_path, config).await
|
||||
}
|
||||
DetectionMethod::EdgeDetection => {
|
||||
Self::edge_detection(image_path, config).await
|
||||
}
|
||||
DetectionMethod::FrequencyAnalysis => {
|
||||
Self::frequency_analysis_detection(image_path, config).await
|
||||
}
|
||||
DetectionMethod::TransparencyDetection => {
|
||||
Self::transparency_detection(image_path, config).await
|
||||
}
|
||||
DetectionMethod::Combined => {
|
||||
// 组合检测:运行所有方法并合并结果
|
||||
let mut all_detections = Vec::new();
|
||||
|
||||
if let Ok(detections) = Self::template_matching_detection(image_path, config).await {
|
||||
all_detections.extend(detections);
|
||||
}
|
||||
|
||||
if let Ok(detections) = Self::edge_detection(image_path, config).await {
|
||||
all_detections.extend(detections);
|
||||
}
|
||||
|
||||
Ok(all_detections)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 模板匹配检测
|
||||
async fn template_matching_detection(
|
||||
image_path: &str,
|
||||
config: &WatermarkDetectionConfig,
|
||||
) -> Result<Vec<WatermarkDetection>> {
|
||||
// TODO: 实现OpenCV模板匹配算法
|
||||
// 这里先返回模拟结果,后续需要集成OpenCV
|
||||
debug!("执行模板匹配检测: {}", image_path);
|
||||
|
||||
// 模拟检测结果
|
||||
let detections = vec![
|
||||
WatermarkDetection {
|
||||
region: BoundingBox {
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 50,
|
||||
},
|
||||
confidence: 0.85,
|
||||
watermark_type: Some(WatermarkType::Image),
|
||||
template_id: None,
|
||||
description: Some("模板匹配检测到的水印".to_string()),
|
||||
}
|
||||
];
|
||||
|
||||
Ok(detections)
|
||||
}
|
||||
|
||||
/// 边缘检测
|
||||
async fn edge_detection(
|
||||
image_path: &str,
|
||||
config: &WatermarkDetectionConfig,
|
||||
) -> Result<Vec<WatermarkDetection>> {
|
||||
// TODO: 实现边缘检测算法
|
||||
debug!("执行边缘检测: {}", image_path);
|
||||
|
||||
// 模拟检测结果
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
/// 频域分析检测
|
||||
async fn frequency_analysis_detection(
|
||||
image_path: &str,
|
||||
config: &WatermarkDetectionConfig,
|
||||
) -> Result<Vec<WatermarkDetection>> {
|
||||
// TODO: 实现频域分析算法
|
||||
debug!("执行频域分析检测: {}", image_path);
|
||||
|
||||
// 模拟检测结果
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
/// 透明度检测
|
||||
async fn transparency_detection(
|
||||
image_path: &str,
|
||||
config: &WatermarkDetectionConfig,
|
||||
) -> Result<Vec<WatermarkDetection>> {
|
||||
// TODO: 实现透明度检测算法
|
||||
debug!("执行透明度检测: {}", image_path);
|
||||
|
||||
// 模拟检测结果
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
/// 合并相似的检测结果
|
||||
fn merge_similar_detections(
|
||||
detections: Vec<WatermarkDetection>,
|
||||
similarity_threshold: f64,
|
||||
) -> Vec<WatermarkDetection> {
|
||||
if detections.is_empty() {
|
||||
return detections;
|
||||
}
|
||||
|
||||
let mut merged = Vec::new();
|
||||
let mut used = vec![false; detections.len()];
|
||||
|
||||
for i in 0..detections.len() {
|
||||
if used[i] {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut group = vec![detections[i].clone()];
|
||||
used[i] = true;
|
||||
|
||||
// 查找相似的检测结果
|
||||
for j in (i + 1)..detections.len() {
|
||||
if used[j] {
|
||||
continue;
|
||||
}
|
||||
|
||||
let similarity = Self::calculate_region_similarity(
|
||||
&detections[i].region,
|
||||
&detections[j].region,
|
||||
);
|
||||
|
||||
if similarity >= similarity_threshold {
|
||||
group.push(detections[j].clone());
|
||||
used[j] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 合并组内的检测结果
|
||||
if group.len() == 1 {
|
||||
merged.push(group[0].clone());
|
||||
} else {
|
||||
merged.push(Self::merge_detection_group(group));
|
||||
}
|
||||
}
|
||||
|
||||
merged
|
||||
}
|
||||
|
||||
/// 计算两个区域的相似度
|
||||
fn calculate_region_similarity(region1: &BoundingBox, region2: &BoundingBox) -> f64 {
|
||||
// 计算重叠区域
|
||||
let x1 = region1.x.max(region2.x);
|
||||
let y1 = region1.y.max(region2.y);
|
||||
let x2 = (region1.x + region1.width).min(region2.x + region2.width);
|
||||
let y2 = (region1.y + region1.height).min(region2.y + region2.height);
|
||||
|
||||
if x2 <= x1 || y2 <= y1 {
|
||||
return 0.0; // 没有重叠
|
||||
}
|
||||
|
||||
let overlap_area = (x2 - x1) * (y2 - y1);
|
||||
let area1 = region1.width * region1.height;
|
||||
let area2 = region2.width * region2.height;
|
||||
let union_area = area1 + area2 - overlap_area;
|
||||
|
||||
if union_area == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
overlap_area as f64 / union_area as f64
|
||||
}
|
||||
|
||||
/// 合并检测组
|
||||
fn merge_detection_group(group: Vec<WatermarkDetection>) -> WatermarkDetection {
|
||||
if group.is_empty() {
|
||||
panic!("检测组不能为空");
|
||||
}
|
||||
|
||||
if group.len() == 1 {
|
||||
return group[0].clone();
|
||||
}
|
||||
|
||||
// 计算边界框的并集
|
||||
let min_x = group.iter().map(|d| d.region.x).min().unwrap();
|
||||
let min_y = group.iter().map(|d| d.region.y).min().unwrap();
|
||||
let max_x = group.iter().map(|d| d.region.x + d.region.width).max().unwrap();
|
||||
let max_y = group.iter().map(|d| d.region.y + d.region.height).max().unwrap();
|
||||
|
||||
// 计算平均置信度
|
||||
let avg_confidence = group.iter().map(|d| d.confidence).sum::<f64>() / group.len() as f64;
|
||||
|
||||
WatermarkDetection {
|
||||
region: BoundingBox {
|
||||
x: min_x,
|
||||
y: min_y,
|
||||
width: max_x - min_x,
|
||||
height: max_y - min_y,
|
||||
},
|
||||
confidence: avg_confidence,
|
||||
watermark_type: group[0].watermark_type.clone(),
|
||||
template_id: group[0].template_id.clone(),
|
||||
description: Some(format!("合并了{}个检测结果", group.len())),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
use anyhow::{Result, anyhow};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use tracing::{info, error, debug};
|
||||
|
||||
use crate::data::models::watermark::{
|
||||
WatermarkRemovalConfig, RemovalMethod, QualityLevel, BoundingBox,
|
||||
WatermarkProcessingResult, WatermarkOperation
|
||||
};
|
||||
use crate::data::repositories::material_repository::MaterialRepository;
|
||||
use crate::infrastructure::ffmpeg_watermark::FFmpegWatermark;
|
||||
use crate::infrastructure::monitoring::PERFORMANCE_MONITOR;
|
||||
|
||||
/// 水印移除服务
|
||||
/// 遵循 Tauri 开发规范的业务逻辑层设计
|
||||
pub struct WatermarkRemovalService;
|
||||
|
||||
impl WatermarkRemovalService {
|
||||
/// 移除视频中的水印
|
||||
pub async fn remove_watermarks_from_video(
|
||||
material_id: &str,
|
||||
input_path: &str,
|
||||
output_path: &str,
|
||||
config: &WatermarkRemovalConfig,
|
||||
repository: Arc<MaterialRepository>,
|
||||
) -> Result<WatermarkProcessingResult> {
|
||||
let timer = PERFORMANCE_MONITOR.start_operation("watermark_removal_video");
|
||||
let start_time = Instant::now();
|
||||
|
||||
info!(
|
||||
material_id = %material_id,
|
||||
input_path = %input_path,
|
||||
output_path = %output_path,
|
||||
method = ?config.method,
|
||||
"开始移除视频水印"
|
||||
);
|
||||
|
||||
// 验证输入文件存在
|
||||
if !Path::new(input_path).exists() {
|
||||
return Err(anyhow!("输入视频文件不存在: {}", input_path));
|
||||
}
|
||||
|
||||
// 确保输出目录存在
|
||||
if let Some(parent) = Path::new(output_path).parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let result = match config.method {
|
||||
RemovalMethod::Blurring => {
|
||||
Self::remove_by_blurring(input_path, output_path, config).await
|
||||
}
|
||||
RemovalMethod::Cropping => {
|
||||
Self::remove_by_cropping(input_path, output_path, config).await
|
||||
}
|
||||
RemovalMethod::Masking => {
|
||||
Self::remove_by_masking(input_path, output_path, config).await
|
||||
}
|
||||
RemovalMethod::Inpainting => {
|
||||
Self::remove_by_inpainting(input_path, output_path, config).await
|
||||
}
|
||||
RemovalMethod::ContentAware => {
|
||||
Self::remove_by_content_aware(input_path, output_path, config).await
|
||||
}
|
||||
RemovalMethod::Clone => {
|
||||
Self::remove_by_clone(input_path, output_path, config).await
|
||||
}
|
||||
};
|
||||
|
||||
let processing_time = start_time.elapsed().as_millis() as u64;
|
||||
|
||||
let processing_result = match result {
|
||||
Ok(_) => {
|
||||
info!(
|
||||
material_id = %material_id,
|
||||
processing_time_ms = processing_time,
|
||||
"视频水印移除成功"
|
||||
);
|
||||
|
||||
WatermarkProcessingResult {
|
||||
material_id: material_id.to_string(),
|
||||
operation: WatermarkOperation::Remove,
|
||||
success: true,
|
||||
output_path: Some(output_path.to_string()),
|
||||
processing_time_ms: processing_time,
|
||||
error_message: None,
|
||||
metadata: None,
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
material_id = %material_id,
|
||||
error = %e,
|
||||
processing_time_ms = processing_time,
|
||||
"视频水印移除失败"
|
||||
);
|
||||
|
||||
WatermarkProcessingResult {
|
||||
material_id: material_id.to_string(),
|
||||
operation: WatermarkOperation::Remove,
|
||||
success: false,
|
||||
output_path: None,
|
||||
processing_time_ms: processing_time,
|
||||
error_message: Some(e.to_string()),
|
||||
metadata: None,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(processing_result)
|
||||
}
|
||||
|
||||
/// 移除图片中的水印
|
||||
pub async fn remove_watermarks_from_image(
|
||||
material_id: &str,
|
||||
input_path: &str,
|
||||
output_path: &str,
|
||||
config: &WatermarkRemovalConfig,
|
||||
) -> Result<WatermarkProcessingResult> {
|
||||
let timer = PERFORMANCE_MONITOR.start_operation("watermark_removal_image");
|
||||
let start_time = Instant::now();
|
||||
|
||||
info!(
|
||||
material_id = %material_id,
|
||||
input_path = %input_path,
|
||||
output_path = %output_path,
|
||||
method = ?config.method,
|
||||
"开始移除图片水印"
|
||||
);
|
||||
|
||||
// 验证输入文件存在
|
||||
if !Path::new(input_path).exists() {
|
||||
return Err(anyhow!("输入图片文件不存在: {}", input_path));
|
||||
}
|
||||
|
||||
// 确保输出目录存在
|
||||
if let Some(parent) = Path::new(output_path).parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let result = match config.method {
|
||||
RemovalMethod::Blurring => {
|
||||
Self::remove_image_by_blurring(input_path, output_path, config).await
|
||||
}
|
||||
RemovalMethod::Cropping => {
|
||||
Self::remove_image_by_cropping(input_path, output_path, config).await
|
||||
}
|
||||
RemovalMethod::Masking => {
|
||||
Self::remove_image_by_masking(input_path, output_path, config).await
|
||||
}
|
||||
RemovalMethod::Inpainting => {
|
||||
Self::remove_image_by_inpainting(input_path, output_path, config).await
|
||||
}
|
||||
RemovalMethod::ContentAware => {
|
||||
Self::remove_image_by_content_aware(input_path, output_path, config).await
|
||||
}
|
||||
RemovalMethod::Clone => {
|
||||
Self::remove_image_by_clone(input_path, output_path, config).await
|
||||
}
|
||||
};
|
||||
|
||||
let processing_time = start_time.elapsed().as_millis() as u64;
|
||||
|
||||
let processing_result = match result {
|
||||
Ok(_) => {
|
||||
info!(
|
||||
material_id = %material_id,
|
||||
processing_time_ms = processing_time,
|
||||
"图片水印移除成功"
|
||||
);
|
||||
|
||||
WatermarkProcessingResult {
|
||||
material_id: material_id.to_string(),
|
||||
operation: WatermarkOperation::Remove,
|
||||
success: true,
|
||||
output_path: Some(output_path.to_string()),
|
||||
processing_time_ms: processing_time,
|
||||
error_message: None,
|
||||
metadata: None,
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
material_id = %material_id,
|
||||
error = %e,
|
||||
processing_time_ms = processing_time,
|
||||
"图片水印移除失败"
|
||||
);
|
||||
|
||||
WatermarkProcessingResult {
|
||||
material_id: material_id.to_string(),
|
||||
operation: WatermarkOperation::Remove,
|
||||
success: false,
|
||||
output_path: None,
|
||||
processing_time_ms: processing_time,
|
||||
error_message: Some(e.to_string()),
|
||||
metadata: None,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(processing_result)
|
||||
}
|
||||
|
||||
/// 通过模糊处理移除水印
|
||||
async fn remove_by_blurring(
|
||||
input_path: &str,
|
||||
output_path: &str,
|
||||
config: &WatermarkRemovalConfig,
|
||||
) -> Result<()> {
|
||||
debug!("使用模糊处理移除水印");
|
||||
|
||||
let blur_radius = config.blur_radius.unwrap_or(10.0);
|
||||
let quality_args = Self::get_quality_args(&config.quality_level);
|
||||
|
||||
// 构建FFmpeg命令
|
||||
let mut args = vec![
|
||||
"-hide_banner",
|
||||
"-loglevel", "error",
|
||||
"-i", input_path,
|
||||
];
|
||||
|
||||
// 添加模糊滤镜
|
||||
let filter = if let Some(regions) = &config.target_regions {
|
||||
// 对指定区域进行模糊处理
|
||||
Self::build_region_blur_filter(regions, blur_radius)
|
||||
} else {
|
||||
// 全局模糊
|
||||
format!("boxblur={}:1", blur_radius)
|
||||
};
|
||||
args.extend_from_slice(&["-vf", &filter]);
|
||||
|
||||
// 添加质量参数
|
||||
args.extend_from_slice(&quality_args);
|
||||
args.extend_from_slice(&["-c:a", "copy", "-y", output_path]);
|
||||
|
||||
FFmpegWatermark::execute_command(&args)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 通过裁剪移除水印
|
||||
async fn remove_by_cropping(
|
||||
input_path: &str,
|
||||
output_path: &str,
|
||||
config: &WatermarkRemovalConfig,
|
||||
) -> Result<()> {
|
||||
debug!("使用裁剪移除水印");
|
||||
|
||||
// 获取视频信息
|
||||
let video_info = FFmpegWatermark::get_video_info(input_path)?;
|
||||
let width = video_info.width.unwrap_or(1920);
|
||||
let height = video_info.height.unwrap_or(1080);
|
||||
|
||||
let margin = config.crop_margin.unwrap_or(50);
|
||||
let quality_args = Self::get_quality_args(&config.quality_level);
|
||||
|
||||
// 计算裁剪区域(移除边缘区域)
|
||||
let crop_width = width.saturating_sub(margin * 2);
|
||||
let crop_height = height.saturating_sub(margin * 2);
|
||||
let crop_x = margin;
|
||||
let crop_y = margin;
|
||||
|
||||
let crop_filter = format!("crop={}:{}:{}:{}", crop_width, crop_height, crop_x, crop_y);
|
||||
|
||||
let mut args = vec![
|
||||
"-hide_banner",
|
||||
"-loglevel", "error",
|
||||
"-i", input_path,
|
||||
"-vf", &crop_filter,
|
||||
];
|
||||
|
||||
args.extend_from_slice(&quality_args);
|
||||
args.extend_from_slice(&["-c:a", "copy", "-y", output_path]);
|
||||
|
||||
FFmpegWatermark::execute_command(&args)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 通过遮罩覆盖移除水印
|
||||
async fn remove_by_masking(
|
||||
input_path: &str,
|
||||
output_path: &str,
|
||||
config: &WatermarkRemovalConfig,
|
||||
) -> Result<()> {
|
||||
debug!("使用遮罩覆盖移除水印");
|
||||
|
||||
// TODO: 实现遮罩覆盖逻辑
|
||||
// 这里需要根据检测到的水印区域生成遮罩
|
||||
|
||||
// 临时实现:使用简单的颜色填充
|
||||
let quality_args = Self::get_quality_args(&config.quality_level);
|
||||
|
||||
let mut args = vec![
|
||||
"-hide_banner",
|
||||
"-loglevel", "error",
|
||||
"-i", input_path,
|
||||
];
|
||||
|
||||
let filter = if let Some(regions) = &config.target_regions {
|
||||
Some(Self::build_region_mask_filter(regions))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(ref filter_str) = filter {
|
||||
args.extend_from_slice(&["-vf", filter_str]);
|
||||
}
|
||||
|
||||
args.extend_from_slice(&quality_args);
|
||||
args.extend_from_slice(&["-c:a", "copy", "-y", output_path]);
|
||||
|
||||
FFmpegWatermark::execute_command(&args)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 通过AI修复移除水印
|
||||
async fn remove_by_inpainting(
|
||||
input_path: &str,
|
||||
output_path: &str,
|
||||
config: &WatermarkRemovalConfig,
|
||||
) -> Result<()> {
|
||||
debug!("使用AI修复移除水印");
|
||||
|
||||
// TODO: 集成AI修复模型
|
||||
// 目前使用简单的模糊处理作为替代
|
||||
Self::remove_by_blurring(input_path, output_path, config).await
|
||||
}
|
||||
|
||||
/// 通过内容感知填充移除水印
|
||||
async fn remove_by_content_aware(
|
||||
input_path: &str,
|
||||
output_path: &str,
|
||||
config: &WatermarkRemovalConfig,
|
||||
) -> Result<()> {
|
||||
debug!("使用内容感知填充移除水印");
|
||||
|
||||
// TODO: 实现内容感知填充算法
|
||||
// 目前使用模糊处理作为替代
|
||||
Self::remove_by_blurring(input_path, output_path, config).await
|
||||
}
|
||||
|
||||
/// 通过克隆修复移除水印
|
||||
async fn remove_by_clone(
|
||||
input_path: &str,
|
||||
output_path: &str,
|
||||
config: &WatermarkRemovalConfig,
|
||||
) -> Result<()> {
|
||||
debug!("使用克隆修复移除水印");
|
||||
|
||||
// TODO: 实现克隆修复算法
|
||||
// 目前使用模糊处理作为替代
|
||||
Self::remove_by_blurring(input_path, output_path, config).await
|
||||
}
|
||||
|
||||
/// 图片模糊处理
|
||||
async fn remove_image_by_blurring(
|
||||
input_path: &str,
|
||||
output_path: &str,
|
||||
config: &WatermarkRemovalConfig,
|
||||
) -> Result<()> {
|
||||
debug!("对图片使用模糊处理移除水印");
|
||||
|
||||
let blur_radius = config.blur_radius.unwrap_or(10.0);
|
||||
|
||||
let blur_filter = format!("boxblur={}:1", blur_radius);
|
||||
let args = vec![
|
||||
"-hide_banner",
|
||||
"-loglevel", "error",
|
||||
"-i", input_path,
|
||||
"-vf", &blur_filter,
|
||||
"-y", output_path,
|
||||
];
|
||||
|
||||
FFmpegWatermark::execute_command(&args)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 图片裁剪处理
|
||||
async fn remove_image_by_cropping(
|
||||
input_path: &str,
|
||||
output_path: &str,
|
||||
config: &WatermarkRemovalConfig,
|
||||
) -> Result<()> {
|
||||
debug!("对图片使用裁剪移除水印");
|
||||
|
||||
// TODO: 获取图片尺寸并计算裁剪区域
|
||||
let margin = config.crop_margin.unwrap_or(50);
|
||||
|
||||
let crop_filter = format!("crop=iw-{}:ih-{}:{}:{}", margin * 2, margin * 2, margin, margin);
|
||||
let args = vec![
|
||||
"-hide_banner",
|
||||
"-loglevel", "error",
|
||||
"-i", input_path,
|
||||
"-vf", &crop_filter,
|
||||
"-y", output_path,
|
||||
];
|
||||
|
||||
FFmpegWatermark::execute_command(&args)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 图片遮罩处理
|
||||
async fn remove_image_by_masking(
|
||||
input_path: &str,
|
||||
output_path: &str,
|
||||
config: &WatermarkRemovalConfig,
|
||||
) -> Result<()> {
|
||||
debug!("对图片使用遮罩移除水印");
|
||||
|
||||
// TODO: 实现图片遮罩处理
|
||||
Self::remove_image_by_blurring(input_path, output_path, config).await
|
||||
}
|
||||
|
||||
/// 图片AI修复
|
||||
async fn remove_image_by_inpainting(
|
||||
input_path: &str,
|
||||
output_path: &str,
|
||||
config: &WatermarkRemovalConfig,
|
||||
) -> Result<()> {
|
||||
debug!("对图片使用AI修复移除水印");
|
||||
|
||||
// TODO: 实现图片AI修复
|
||||
Self::remove_image_by_blurring(input_path, output_path, config).await
|
||||
}
|
||||
|
||||
/// 图片内容感知填充
|
||||
async fn remove_image_by_content_aware(
|
||||
input_path: &str,
|
||||
output_path: &str,
|
||||
config: &WatermarkRemovalConfig,
|
||||
) -> Result<()> {
|
||||
debug!("对图片使用内容感知填充移除水印");
|
||||
|
||||
// TODO: 实现图片内容感知填充
|
||||
Self::remove_image_by_blurring(input_path, output_path, config).await
|
||||
}
|
||||
|
||||
/// 图片克隆修复
|
||||
async fn remove_image_by_clone(
|
||||
input_path: &str,
|
||||
output_path: &str,
|
||||
config: &WatermarkRemovalConfig,
|
||||
) -> Result<()> {
|
||||
debug!("对图片使用克隆修复移除水印");
|
||||
|
||||
// TODO: 实现图片克隆修复
|
||||
Self::remove_image_by_blurring(input_path, output_path, config).await
|
||||
}
|
||||
|
||||
/// 构建区域模糊滤镜
|
||||
fn build_region_blur_filter(regions: &[BoundingBox], blur_radius: f32) -> String {
|
||||
let mut filters = Vec::new();
|
||||
|
||||
for (i, region) in regions.iter().enumerate() {
|
||||
let filter = format!(
|
||||
"boxblur={}:1:enable='between(t,0,999999)*between(x,{},{})*between(y,{},{})'",
|
||||
blur_radius,
|
||||
region.x,
|
||||
region.x + region.width,
|
||||
region.y,
|
||||
region.y + region.height
|
||||
);
|
||||
filters.push(filter);
|
||||
}
|
||||
|
||||
filters.join(",")
|
||||
}
|
||||
|
||||
/// 构建区域遮罩滤镜
|
||||
fn build_region_mask_filter(regions: &[BoundingBox]) -> String {
|
||||
let mut filters = Vec::new();
|
||||
|
||||
for region in regions {
|
||||
let filter = format!(
|
||||
"drawbox=x={}:y={}:w={}:h={}:color=black:t=fill",
|
||||
region.x,
|
||||
region.y,
|
||||
region.width,
|
||||
region.height
|
||||
);
|
||||
filters.push(filter);
|
||||
}
|
||||
|
||||
filters.join(",")
|
||||
}
|
||||
|
||||
/// 获取质量参数
|
||||
fn get_quality_args(quality_level: &QualityLevel) -> Vec<&'static str> {
|
||||
match quality_level {
|
||||
QualityLevel::Low => vec!["-c:v", "libx264", "-preset", "ultrafast", "-crf", "28"],
|
||||
QualityLevel::Medium => vec!["-c:v", "libx264", "-preset", "fast", "-crf", "23"],
|
||||
QualityLevel::High => vec!["-c:v", "libx264", "-preset", "slow", "-crf", "18"],
|
||||
QualityLevel::Lossless => vec!["-c:v", "libx264", "-preset", "veryslow", "-crf", "0"],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
use anyhow::{Result, anyhow};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::fs;
|
||||
use std::time::Instant;
|
||||
use tracing::{info, debug};
|
||||
|
||||
use crate::data::models::watermark::{
|
||||
WatermarkTemplate, WatermarkCategory, WatermarkType
|
||||
};
|
||||
use crate::data::repositories::watermark_template_repository::WatermarkTemplateRepository;
|
||||
use crate::infrastructure::ffmpeg_watermark::FFmpegWatermark;
|
||||
use crate::infrastructure::monitoring::PERFORMANCE_MONITOR;
|
||||
|
||||
/// 水印模板管理服务
|
||||
/// 遵循 Tauri 开发规范的业务逻辑层设计
|
||||
pub struct WatermarkTemplateService;
|
||||
|
||||
impl WatermarkTemplateService {
|
||||
/// 上传并创建水印模板
|
||||
pub async fn upload_template(
|
||||
repository: Arc<WatermarkTemplateRepository>,
|
||||
name: String,
|
||||
source_file_path: String,
|
||||
category: WatermarkCategory,
|
||||
watermark_type: WatermarkType,
|
||||
description: Option<String>,
|
||||
tags: Vec<String>,
|
||||
) -> Result<WatermarkTemplate> {
|
||||
let timer = PERFORMANCE_MONITOR.start_operation("upload_watermark_template");
|
||||
let start_time = Instant::now();
|
||||
|
||||
info!(
|
||||
name = %name,
|
||||
source_file_path = %source_file_path,
|
||||
category = ?category,
|
||||
watermark_type = ?watermark_type,
|
||||
"开始上传水印模板"
|
||||
);
|
||||
|
||||
// 验证源文件存在
|
||||
if !Path::new(&source_file_path).exists() {
|
||||
return Err(anyhow!("源文件不存在: {}", source_file_path));
|
||||
}
|
||||
|
||||
// 检查模板名称是否已存在
|
||||
if repository.name_exists(&name, None)? {
|
||||
return Err(anyhow!("模板名称已存在: {}", name));
|
||||
}
|
||||
|
||||
// 验证文件格式
|
||||
Self::validate_file_format(&source_file_path, &watermark_type)?;
|
||||
|
||||
// 创建模板目录
|
||||
let template_id = uuid::Uuid::new_v4().to_string();
|
||||
let template_dir = Self::get_template_directory(&template_id);
|
||||
fs::create_dir_all(&template_dir)?;
|
||||
|
||||
// 复制文件到模板目录
|
||||
let file_extension = Path::new(&source_file_path)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.unwrap_or("unknown");
|
||||
let template_filename = format!("template.{}", file_extension);
|
||||
let template_file_path = template_dir.join(&template_filename);
|
||||
|
||||
fs::copy(&source_file_path, &template_file_path)?;
|
||||
|
||||
// 获取文件信息
|
||||
let file_metadata = fs::metadata(&template_file_path)?;
|
||||
let file_size = file_metadata.len();
|
||||
|
||||
// 获取图片/视频尺寸
|
||||
let (width, height) = Self::get_media_dimensions(&template_file_path.to_string_lossy().to_string())?;
|
||||
|
||||
// 生成缩略图
|
||||
let thumbnail_path = Self::generate_thumbnail(
|
||||
&template_file_path.to_string_lossy().to_string(),
|
||||
&template_dir,
|
||||
&watermark_type,
|
||||
).await?;
|
||||
|
||||
// 创建模板对象
|
||||
let template = WatermarkTemplate {
|
||||
id: template_id,
|
||||
name,
|
||||
file_path: template_file_path.to_string_lossy().to_string(),
|
||||
thumbnail_path: Some(thumbnail_path),
|
||||
category,
|
||||
watermark_type,
|
||||
file_size,
|
||||
width,
|
||||
height,
|
||||
description,
|
||||
tags,
|
||||
is_active: true,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
// 保存到数据库
|
||||
repository.create(&template)?;
|
||||
|
||||
let processing_time = start_time.elapsed().as_millis() as u64;
|
||||
info!(
|
||||
template_id = %template.id,
|
||||
processing_time_ms = processing_time,
|
||||
"水印模板上传成功"
|
||||
);
|
||||
|
||||
Ok(template)
|
||||
}
|
||||
|
||||
/// 更新水印模板
|
||||
pub async fn update_template(
|
||||
repository: Arc<WatermarkTemplateRepository>,
|
||||
template_id: String,
|
||||
name: Option<String>,
|
||||
description: Option<String>,
|
||||
tags: Option<Vec<String>>,
|
||||
category: Option<WatermarkCategory>,
|
||||
) -> Result<WatermarkTemplate> {
|
||||
info!(template_id = %template_id, "开始更新水印模板");
|
||||
|
||||
// 获取现有模板
|
||||
let mut template = repository.get_by_id(&template_id)?
|
||||
.ok_or_else(|| anyhow!("模板不存在: {}", template_id))?;
|
||||
|
||||
// 检查名称是否冲突
|
||||
if let Some(ref new_name) = name {
|
||||
if repository.name_exists(new_name, Some(&template_id))? {
|
||||
return Err(anyhow!("模板名称已存在: {}", new_name));
|
||||
}
|
||||
template.name = new_name.clone();
|
||||
}
|
||||
|
||||
// 更新其他字段
|
||||
if let Some(new_description) = description {
|
||||
template.description = Some(new_description);
|
||||
}
|
||||
|
||||
if let Some(new_tags) = tags {
|
||||
template.tags = new_tags;
|
||||
}
|
||||
|
||||
if let Some(new_category) = category {
|
||||
template.category = new_category;
|
||||
}
|
||||
|
||||
template.updated_at = chrono::Utc::now();
|
||||
|
||||
// 保存更新
|
||||
repository.update(&template)?;
|
||||
|
||||
info!(template_id = %template_id, "水印模板更新成功");
|
||||
Ok(template)
|
||||
}
|
||||
|
||||
/// 删除水印模板
|
||||
pub async fn delete_template(
|
||||
repository: Arc<WatermarkTemplateRepository>,
|
||||
template_id: String,
|
||||
hard_delete: bool,
|
||||
) -> Result<()> {
|
||||
info!(template_id = %template_id, hard_delete = hard_delete, "开始删除水印模板");
|
||||
|
||||
// 获取模板信息
|
||||
let template = repository.get_by_id(&template_id)?
|
||||
.ok_or_else(|| anyhow!("模板不存在: {}", template_id))?;
|
||||
|
||||
if hard_delete {
|
||||
// 硬删除:删除文件和数据库记录
|
||||
let template_dir = Self::get_template_directory(&template_id);
|
||||
if template_dir.exists() {
|
||||
fs::remove_dir_all(&template_dir)?;
|
||||
}
|
||||
repository.delete(&template_id)?;
|
||||
} else {
|
||||
// 软删除:只标记为非活跃
|
||||
repository.soft_delete(&template_id)?;
|
||||
}
|
||||
|
||||
info!(template_id = %template_id, "水印模板删除成功");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取模板列表
|
||||
pub async fn get_templates(
|
||||
repository: Arc<WatermarkTemplateRepository>,
|
||||
category: Option<WatermarkCategory>,
|
||||
watermark_type: Option<WatermarkType>,
|
||||
search_query: Option<String>,
|
||||
) -> Result<Vec<WatermarkTemplate>> {
|
||||
debug!("获取水印模板列表");
|
||||
|
||||
let templates = if let Some(query) = search_query {
|
||||
repository.search(&query)?
|
||||
} else if let Some(cat) = category {
|
||||
repository.get_by_category(&cat)?
|
||||
} else if let Some(wtype) = watermark_type {
|
||||
repository.get_by_type(&wtype)?
|
||||
} else {
|
||||
repository.get_all()?
|
||||
};
|
||||
|
||||
Ok(templates)
|
||||
}
|
||||
|
||||
/// 获取模板统计信息
|
||||
pub async fn get_template_stats(
|
||||
repository: Arc<WatermarkTemplateRepository>,
|
||||
) -> Result<serde_json::Value> {
|
||||
debug!("获取水印模板统计信息");
|
||||
repository.get_stats()
|
||||
}
|
||||
|
||||
/// 验证文件格式
|
||||
fn validate_file_format(file_path: &str, watermark_type: &WatermarkType) -> Result<()> {
|
||||
let path = Path::new(file_path);
|
||||
let extension = path.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.ok_or_else(|| anyhow!("无法获取文件扩展名"))?
|
||||
.to_lowercase();
|
||||
|
||||
let valid_extensions = match watermark_type {
|
||||
WatermarkType::Image => vec!["png", "jpg", "jpeg", "bmp", "tiff", "webp"],
|
||||
WatermarkType::Vector => vec!["svg"],
|
||||
WatermarkType::Animated => vec!["gif", "webp", "apng"],
|
||||
WatermarkType::Text => vec!["txt", "json"], // 文字水印配置文件
|
||||
};
|
||||
|
||||
if !valid_extensions.contains(&extension.as_str()) {
|
||||
return Err(anyhow!(
|
||||
"不支持的文件格式: {},支持的格式: {:?}",
|
||||
extension,
|
||||
valid_extensions
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取媒体文件尺寸
|
||||
fn get_media_dimensions(file_path: &str) -> Result<(Option<u32>, Option<u32>)> {
|
||||
let path = Path::new(file_path);
|
||||
let extension = path.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
|
||||
match extension.as_str() {
|
||||
"png" | "jpg" | "jpeg" | "bmp" | "tiff" | "webp" | "gif" => {
|
||||
// 对于图片文件,可以使用image库获取尺寸
|
||||
// 这里先返回None,后续可以集成image库
|
||||
Ok((None, None))
|
||||
}
|
||||
"svg" => {
|
||||
// SVG文件需要解析XML获取viewBox或width/height属性
|
||||
Ok((None, None))
|
||||
}
|
||||
_ => Ok((None, None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成缩略图
|
||||
async fn generate_thumbnail(
|
||||
file_path: &str,
|
||||
output_dir: &Path,
|
||||
watermark_type: &WatermarkType,
|
||||
) -> Result<String> {
|
||||
let thumbnail_path = output_dir.join("thumbnail.jpg");
|
||||
let thumbnail_path_str = thumbnail_path.to_string_lossy().to_string();
|
||||
|
||||
match watermark_type {
|
||||
WatermarkType::Image | WatermarkType::Animated => {
|
||||
// 对于图片和动画,使用FFmpeg生成缩略图
|
||||
FFmpegWatermark::extract_frame_at_timestamp(
|
||||
file_path,
|
||||
0.0, // 第一帧
|
||||
&thumbnail_path_str,
|
||||
200, // 缩略图宽度
|
||||
150, // 缩略图高度
|
||||
)?;
|
||||
}
|
||||
WatermarkType::Vector => {
|
||||
// 对于SVG,需要转换为PNG再生成缩略图
|
||||
// 这里先复制原文件作为缩略图
|
||||
fs::copy(file_path, &thumbnail_path)?;
|
||||
}
|
||||
WatermarkType::Text => {
|
||||
// 对于文字水印,生成一个文字预览图
|
||||
Self::generate_text_thumbnail(file_path, &thumbnail_path_str)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(thumbnail_path_str)
|
||||
}
|
||||
|
||||
/// 生成文字水印缩略图
|
||||
fn generate_text_thumbnail(text_file_path: &str, output_path: &str) -> Result<()> {
|
||||
// TODO: 实现文字水印缩略图生成
|
||||
// 可以使用图像库生成包含文字的预览图
|
||||
|
||||
// 临时实现:创建一个空的缩略图文件
|
||||
fs::write(output_path, b"")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取模板目录路径
|
||||
fn get_template_directory(template_id: &str) -> PathBuf {
|
||||
PathBuf::from("watermarks")
|
||||
.join("templates")
|
||||
.join(template_id)
|
||||
}
|
||||
|
||||
/// 导出模板
|
||||
pub async fn export_template(
|
||||
repository: Arc<WatermarkTemplateRepository>,
|
||||
template_id: String,
|
||||
export_path: String,
|
||||
) -> Result<()> {
|
||||
info!(template_id = %template_id, export_path = %export_path, "开始导出水印模板");
|
||||
|
||||
// 获取模板信息
|
||||
let template = repository.get_by_id(&template_id)?
|
||||
.ok_or_else(|| anyhow!("模板不存在: {}", template_id))?;
|
||||
|
||||
// 创建导出目录
|
||||
let export_dir = Path::new(&export_path);
|
||||
fs::create_dir_all(export_dir)?;
|
||||
|
||||
// 复制模板文件
|
||||
let template_file = Path::new(&template.file_path);
|
||||
if template_file.exists() {
|
||||
let export_file = export_dir.join(template_file.file_name().unwrap());
|
||||
fs::copy(template_file, export_file)?;
|
||||
}
|
||||
|
||||
// 复制缩略图
|
||||
if let Some(ref thumbnail_path) = template.thumbnail_path {
|
||||
let thumbnail_file = Path::new(thumbnail_path);
|
||||
if thumbnail_file.exists() {
|
||||
let export_thumbnail = export_dir.join("thumbnail.jpg");
|
||||
fs::copy(thumbnail_file, export_thumbnail)?;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建模板信息文件
|
||||
let template_info = serde_json::json!({
|
||||
"id": template.id,
|
||||
"name": template.name,
|
||||
"category": template.category,
|
||||
"watermark_type": template.watermark_type,
|
||||
"description": template.description,
|
||||
"tags": template.tags,
|
||||
"created_at": template.created_at,
|
||||
"updated_at": template.updated_at
|
||||
});
|
||||
|
||||
let info_file = export_dir.join("template_info.json");
|
||||
fs::write(info_file, serde_json::to_string_pretty(&template_info)?)?;
|
||||
|
||||
info!(template_id = %template_id, "水印模板导出成功");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 导入模板
|
||||
pub async fn import_template(
|
||||
repository: Arc<WatermarkTemplateRepository>,
|
||||
import_path: String,
|
||||
) -> Result<WatermarkTemplate> {
|
||||
info!(import_path = %import_path, "开始导入水印模板");
|
||||
|
||||
let import_dir = Path::new(&import_path);
|
||||
if !import_dir.is_dir() {
|
||||
return Err(anyhow!("导入路径必须是目录: {}", import_path));
|
||||
}
|
||||
|
||||
// 读取模板信息文件
|
||||
let info_file = import_dir.join("template_info.json");
|
||||
if !info_file.exists() {
|
||||
return Err(anyhow!("缺少模板信息文件: template_info.json"));
|
||||
}
|
||||
|
||||
let info_content = fs::read_to_string(info_file)?;
|
||||
let template_info: serde_json::Value = serde_json::from_str(&info_content)?;
|
||||
|
||||
// 提取模板信息
|
||||
let name = template_info["name"].as_str()
|
||||
.ok_or_else(|| anyhow!("缺少模板名称"))?;
|
||||
let category: WatermarkCategory = serde_json::from_value(template_info["category"].clone())?;
|
||||
let watermark_type: WatermarkType = serde_json::from_value(template_info["watermark_type"].clone())?;
|
||||
let description = template_info["description"].as_str().map(|s| s.to_string());
|
||||
let tags: Vec<String> = serde_json::from_value(template_info["tags"].clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
// 查找模板文件
|
||||
let template_files: Vec<_> = fs::read_dir(import_dir)?
|
||||
.filter_map(|entry| entry.ok())
|
||||
.filter(|entry| {
|
||||
let path = entry.path();
|
||||
path.is_file() &&
|
||||
path.file_name().unwrap().to_str().unwrap() != "template_info.json" &&
|
||||
path.file_name().unwrap().to_str().unwrap() != "thumbnail.jpg"
|
||||
})
|
||||
.collect();
|
||||
|
||||
if template_files.is_empty() {
|
||||
return Err(anyhow!("未找到模板文件"));
|
||||
}
|
||||
|
||||
let template_file = &template_files[0];
|
||||
let template_file_path = template_file.path().to_string_lossy().to_string();
|
||||
|
||||
// 使用上传功能创建模板
|
||||
Self::upload_template(
|
||||
repository,
|
||||
name.to_string(),
|
||||
template_file_path,
|
||||
category,
|
||||
watermark_type,
|
||||
description,
|
||||
tags,
|
||||
).await
|
||||
}
|
||||
}
|
||||
@@ -15,3 +15,4 @@ pub mod conversation;
|
||||
pub mod outfit_search;
|
||||
pub mod gemini_analysis;
|
||||
pub mod custom_tag;
|
||||
pub mod watermark;
|
||||
|
||||
341
apps/desktop/src-tauri/src/data/models/watermark.rs
Normal file
341
apps/desktop/src-tauri/src/data/models/watermark.rs
Normal file
@@ -0,0 +1,341 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
/// 水印模板实体模型
|
||||
/// 遵循 Tauri 开发规范的数据模型设计原则
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WatermarkTemplate {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub file_path: String,
|
||||
pub thumbnail_path: Option<String>,
|
||||
pub category: WatermarkCategory,
|
||||
pub watermark_type: WatermarkType,
|
||||
pub file_size: u64,
|
||||
pub width: Option<u32>,
|
||||
pub height: Option<u32>,
|
||||
pub description: Option<String>,
|
||||
pub tags: Vec<String>,
|
||||
pub is_active: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 水印类型枚举
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum WatermarkType {
|
||||
Image, // 图片水印 (PNG, JPG)
|
||||
Vector, // 矢量水印 (SVG)
|
||||
Text, // 文字水印
|
||||
Animated, // 动态水印 (GIF, 动画)
|
||||
}
|
||||
|
||||
/// 水印分类枚举
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum WatermarkCategory {
|
||||
Logo, // 品牌标识
|
||||
Copyright, // 版权标识
|
||||
Signature, // 签名
|
||||
Decoration, // 装饰性
|
||||
Custom, // 自定义
|
||||
}
|
||||
|
||||
/// 水印检测结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WatermarkDetectionResult {
|
||||
pub id: String,
|
||||
pub material_id: String,
|
||||
pub detection_method: DetectionMethod,
|
||||
pub detections: Vec<WatermarkDetection>,
|
||||
pub confidence_score: f64,
|
||||
pub processing_time_ms: u64,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 单个水印检测信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WatermarkDetection {
|
||||
pub region: BoundingBox,
|
||||
pub confidence: f64,
|
||||
pub watermark_type: Option<WatermarkType>,
|
||||
pub template_id: Option<String>, // 匹配的模板ID(如果使用模板匹配)
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
/// 边界框定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BoundingBox {
|
||||
pub x: u32,
|
||||
pub y: u32,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
/// 检测方法枚举
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum DetectionMethod {
|
||||
TemplateMatching, // 模板匹配
|
||||
EdgeDetection, // 边缘检测
|
||||
FrequencyAnalysis, // 频域分析
|
||||
TransparencyDetection, // 透明度检测
|
||||
Combined, // 组合检测
|
||||
}
|
||||
|
||||
/// 水印配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WatermarkConfig {
|
||||
pub watermark_type: WatermarkType,
|
||||
pub position: WatermarkPosition,
|
||||
pub opacity: f32, // 透明度 0.0-1.0
|
||||
pub scale: f32, // 缩放比例
|
||||
pub rotation: f32, // 旋转角度(度)
|
||||
pub animation: Option<WatermarkAnimation>,
|
||||
pub blend_mode: BlendMode,
|
||||
pub quality_level: QualityLevel,
|
||||
}
|
||||
|
||||
/// 水印位置配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum WatermarkPosition {
|
||||
TopLeft,
|
||||
TopCenter,
|
||||
TopRight,
|
||||
MiddleLeft,
|
||||
Center,
|
||||
MiddleRight,
|
||||
BottomLeft,
|
||||
BottomCenter,
|
||||
BottomRight,
|
||||
Custom { x: f32, y: f32 }, // 相对位置 0.0-1.0
|
||||
Dynamic(DynamicPositionRule), // 动态位置
|
||||
}
|
||||
|
||||
/// 动态位置规则
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DynamicPositionRule {
|
||||
pub avoid_faces: bool, // 避开人脸
|
||||
pub avoid_text: bool, // 避开文字
|
||||
pub follow_motion: bool, // 跟随运动
|
||||
pub corner_preference: Vec<Corner>, // 角落偏好
|
||||
pub min_distance_from_edge: u32, // 距离边缘最小像素
|
||||
}
|
||||
|
||||
/// 角落枚举
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum Corner {
|
||||
TopLeft,
|
||||
TopRight,
|
||||
BottomLeft,
|
||||
BottomRight,
|
||||
}
|
||||
|
||||
/// 水印动画配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WatermarkAnimation {
|
||||
pub animation_type: AnimationType,
|
||||
pub duration_ms: u32,
|
||||
pub loop_count: Option<u32>, // None表示无限循环
|
||||
pub easing: EasingFunction,
|
||||
}
|
||||
|
||||
/// 动画类型
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum AnimationType {
|
||||
FadeIn, // 淡入
|
||||
FadeOut, // 淡出
|
||||
SlideIn, // 滑入
|
||||
SlideOut, // 滑出
|
||||
Rotate, // 旋转
|
||||
Scale, // 缩放
|
||||
Pulse, // 脉冲
|
||||
Custom(String), // 自定义动画
|
||||
}
|
||||
|
||||
/// 缓动函数
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum EasingFunction {
|
||||
Linear,
|
||||
EaseIn,
|
||||
EaseOut,
|
||||
EaseInOut,
|
||||
Bounce,
|
||||
Elastic,
|
||||
}
|
||||
|
||||
/// 混合模式
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum BlendMode {
|
||||
Normal,
|
||||
Multiply,
|
||||
Screen,
|
||||
Overlay,
|
||||
SoftLight,
|
||||
HardLight,
|
||||
ColorDodge,
|
||||
ColorBurn,
|
||||
}
|
||||
|
||||
/// 质量级别
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum QualityLevel {
|
||||
Low, // 快速处理,质量较低
|
||||
Medium, // 平衡处理,中等质量
|
||||
High, // 高质量处理,速度较慢
|
||||
Lossless, // 无损处理,最高质量
|
||||
}
|
||||
|
||||
/// 水印移除配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WatermarkRemovalConfig {
|
||||
pub method: RemovalMethod,
|
||||
pub quality_level: QualityLevel,
|
||||
pub preserve_aspect_ratio: bool,
|
||||
pub target_regions: Option<Vec<BoundingBox>>, // 指定移除区域
|
||||
pub inpainting_model: Option<String>, // AI修复模型
|
||||
pub blur_radius: Option<f32>, // 模糊半径
|
||||
pub crop_margin: Option<u32>, // 裁剪边距
|
||||
}
|
||||
|
||||
/// 移除方法
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum RemovalMethod {
|
||||
Inpainting, // AI修复
|
||||
Blurring, // 模糊处理
|
||||
Cropping, // 裁剪移除
|
||||
Masking, // 遮罩覆盖
|
||||
ContentAware, // 内容感知填充
|
||||
Clone, // 克隆修复
|
||||
}
|
||||
|
||||
/// 水印检测配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WatermarkDetectionConfig {
|
||||
pub similarity_threshold: f64, // 相似度阈值 0.0-1.0
|
||||
pub min_watermark_size: (u32, u32), // 最小水印尺寸
|
||||
pub max_watermark_size: (u32, u32), // 最大水印尺寸
|
||||
pub detection_regions: Vec<DetectionRegion>, // 检测区域
|
||||
pub frame_sample_rate: u32, // 视频帧采样率
|
||||
pub methods: Vec<DetectionMethod>, // 使用的检测方法
|
||||
pub template_ids: Option<Vec<String>>, // 指定模板ID列表
|
||||
}
|
||||
|
||||
/// 检测区域
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum DetectionRegion {
|
||||
FullFrame, // 全帧检测
|
||||
Corners, // 四角检测
|
||||
Edges, // 边缘检测
|
||||
Center, // 中心区域
|
||||
Custom(BoundingBox), // 自定义区域
|
||||
}
|
||||
|
||||
/// 批量水印处理任务
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BatchWatermarkTask {
|
||||
pub task_id: String,
|
||||
pub operation: WatermarkOperation,
|
||||
pub material_ids: Vec<String>,
|
||||
pub config: serde_json::Value, // 动态配置,根据operation类型解析
|
||||
pub status: BatchTaskStatus,
|
||||
pub progress: BatchProgress,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub started_at: Option<DateTime<Utc>>,
|
||||
pub completed_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// 水印操作类型
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum WatermarkOperation {
|
||||
Detect,
|
||||
Remove,
|
||||
Add,
|
||||
DetectAndRemove,
|
||||
Replace, // 检测并替换
|
||||
}
|
||||
|
||||
/// 批量任务状态
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum BatchTaskStatus {
|
||||
Pending, // 等待中
|
||||
Running, // 执行中
|
||||
Completed, // 已完成
|
||||
Failed, // 失败
|
||||
Cancelled, // 已取消
|
||||
Paused, // 已暂停
|
||||
}
|
||||
|
||||
/// 批量处理进度
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BatchProgress {
|
||||
pub total_items: u32,
|
||||
pub processed_items: u32,
|
||||
pub failed_items: u32,
|
||||
pub current_item: Option<String>, // 当前处理的素材ID
|
||||
pub progress_percentage: f32, // 进度百分比 0.0-100.0
|
||||
pub estimated_remaining_ms: Option<u64>, // 预估剩余时间(毫秒)
|
||||
pub errors: Vec<String>, // 错误信息列表
|
||||
pub detection_results: Vec<WatermarkDetectionResult>, // 检测结果列表
|
||||
pub processing_results: Vec<WatermarkProcessingResult>, // 处理结果列表
|
||||
}
|
||||
|
||||
/// 水印处理结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WatermarkProcessingResult {
|
||||
pub material_id: String,
|
||||
pub operation: WatermarkOperation,
|
||||
pub success: bool,
|
||||
pub output_path: Option<String>,
|
||||
pub processing_time_ms: u64,
|
||||
pub error_message: Option<String>,
|
||||
pub metadata: Option<serde_json::Value>, // 额外的处理元数据
|
||||
}
|
||||
|
||||
impl Default for WatermarkConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
watermark_type: WatermarkType::Image,
|
||||
position: WatermarkPosition::BottomRight,
|
||||
opacity: 0.8,
|
||||
scale: 1.0,
|
||||
rotation: 0.0,
|
||||
animation: None,
|
||||
blend_mode: BlendMode::Normal,
|
||||
quality_level: QualityLevel::Medium,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WatermarkDetectionConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
similarity_threshold: 0.8,
|
||||
min_watermark_size: (32, 32),
|
||||
max_watermark_size: (512, 512),
|
||||
detection_regions: vec![
|
||||
DetectionRegion::Corners,
|
||||
DetectionRegion::Center,
|
||||
],
|
||||
frame_sample_rate: 30, // 每30帧采样一次
|
||||
methods: vec![
|
||||
DetectionMethod::TemplateMatching,
|
||||
DetectionMethod::EdgeDetection,
|
||||
],
|
||||
template_ids: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WatermarkRemovalConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
method: RemovalMethod::Inpainting,
|
||||
quality_level: QualityLevel::Medium,
|
||||
preserve_aspect_ratio: true,
|
||||
target_regions: None,
|
||||
inpainting_model: None,
|
||||
blur_radius: Some(5.0),
|
||||
crop_margin: Some(10),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use crate::infrastructure::database::Database;
|
||||
|
||||
/// 素材仓库
|
||||
/// 遵循 Tauri 开发规范的数据访问层设计
|
||||
#[derive(Clone)]
|
||||
pub struct MaterialRepository {
|
||||
database: Arc<Database>,
|
||||
}
|
||||
|
||||
@@ -11,3 +11,4 @@ pub mod export_record_repository;
|
||||
pub mod video_generation_repository;
|
||||
pub mod conversation_repository;
|
||||
pub mod custom_tag_repository;
|
||||
pub mod watermark_template_repository;
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
use anyhow::{Result, anyhow};
|
||||
use rusqlite::{params, Row};
|
||||
use std::sync::Arc;
|
||||
use tracing::info;
|
||||
|
||||
use crate::data::models::watermark::{
|
||||
WatermarkTemplate, WatermarkCategory, WatermarkType
|
||||
};
|
||||
use crate::infrastructure::database::Database;
|
||||
|
||||
/// 水印模板数据访问层
|
||||
/// 遵循 Tauri 开发规范的数据访问层设计
|
||||
pub struct WatermarkTemplateRepository {
|
||||
database: Arc<Database>,
|
||||
}
|
||||
|
||||
impl WatermarkTemplateRepository {
|
||||
pub fn new(database: Arc<Database>) -> Self {
|
||||
Self { database }
|
||||
}
|
||||
|
||||
/// 创建水印模板
|
||||
pub fn create(&self, template: &WatermarkTemplate) -> Result<()> {
|
||||
let conn = self.database.get_connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO watermark_templates (
|
||||
id, name, file_path, thumbnail_path, category, watermark_type,
|
||||
file_size, width, height, description, tags, is_active,
|
||||
created_at, updated_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
|
||||
params![
|
||||
template.id,
|
||||
template.name,
|
||||
template.file_path,
|
||||
template.thumbnail_path,
|
||||
serde_json::to_string(&template.category)?,
|
||||
serde_json::to_string(&template.watermark_type)?,
|
||||
template.file_size as i64,
|
||||
template.width.map(|w| w as i32),
|
||||
template.height.map(|h| h as i32),
|
||||
template.description,
|
||||
serde_json::to_string(&template.tags)?,
|
||||
template.is_active,
|
||||
template.created_at.format("%Y-%m-%d %H:%M:%S").to_string(),
|
||||
template.updated_at.format("%Y-%m-%d %H:%M:%S").to_string(),
|
||||
],
|
||||
)?;
|
||||
|
||||
info!(template_id = %template.id, template_name = %template.name, "水印模板创建成功");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 根据ID获取水印模板
|
||||
pub fn get_by_id(&self, id: &str) -> Result<Option<WatermarkTemplate>> {
|
||||
let conn = self.database.get_connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, name, file_path, thumbnail_path, category, watermark_type,
|
||||
file_size, width, height, description, tags, is_active,
|
||||
created_at, updated_at
|
||||
FROM watermark_templates
|
||||
WHERE id = ?1"
|
||||
)?;
|
||||
|
||||
let template_iter = stmt.query_map([id], |row| {
|
||||
Self::row_to_template(row)
|
||||
})?;
|
||||
|
||||
for template in template_iter {
|
||||
return Ok(Some(template?));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// 获取所有水印模板
|
||||
pub fn get_all(&self) -> Result<Vec<WatermarkTemplate>> {
|
||||
let conn = self.database.get_connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, name, file_path, thumbnail_path, category, watermark_type,
|
||||
file_size, width, height, description, tags, is_active,
|
||||
created_at, updated_at
|
||||
FROM watermark_templates
|
||||
ORDER BY created_at DESC"
|
||||
)?;
|
||||
|
||||
let template_iter = stmt.query_map([], |row| {
|
||||
Self::row_to_template(row)
|
||||
})?;
|
||||
|
||||
let mut templates = Vec::new();
|
||||
for template in template_iter {
|
||||
templates.push(template?);
|
||||
}
|
||||
|
||||
Ok(templates)
|
||||
}
|
||||
|
||||
/// 根据分类获取水印模板
|
||||
pub fn get_by_category(&self, category: &WatermarkCategory) -> Result<Vec<WatermarkTemplate>> {
|
||||
let conn = self.database.get_connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
let category_str = serde_json::to_string(category)?;
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, name, file_path, thumbnail_path, category, watermark_type,
|
||||
file_size, width, height, description, tags, is_active,
|
||||
created_at, updated_at
|
||||
FROM watermark_templates
|
||||
WHERE category = ?1 AND is_active = 1
|
||||
ORDER BY created_at DESC"
|
||||
)?;
|
||||
|
||||
let template_iter = stmt.query_map([category_str], |row| {
|
||||
Self::row_to_template(row)
|
||||
})?;
|
||||
|
||||
let mut templates = Vec::new();
|
||||
for template in template_iter {
|
||||
templates.push(template?);
|
||||
}
|
||||
|
||||
Ok(templates)
|
||||
}
|
||||
|
||||
/// 根据类型获取水印模板
|
||||
pub fn get_by_type(&self, watermark_type: &WatermarkType) -> Result<Vec<WatermarkTemplate>> {
|
||||
let conn = self.database.get_connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
let type_str = serde_json::to_string(watermark_type)?;
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, name, file_path, thumbnail_path, category, watermark_type,
|
||||
file_size, width, height, description, tags, is_active,
|
||||
created_at, updated_at
|
||||
FROM watermark_templates
|
||||
WHERE watermark_type = ?1 AND is_active = 1
|
||||
ORDER BY created_at DESC"
|
||||
)?;
|
||||
|
||||
let template_iter = stmt.query_map([type_str], |row| {
|
||||
Self::row_to_template(row)
|
||||
})?;
|
||||
|
||||
let mut templates = Vec::new();
|
||||
for template in template_iter {
|
||||
templates.push(template?);
|
||||
}
|
||||
|
||||
Ok(templates)
|
||||
}
|
||||
|
||||
/// 搜索水印模板
|
||||
pub fn search(&self, query: &str) -> Result<Vec<WatermarkTemplate>> {
|
||||
let conn = self.database.get_connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
let search_pattern = format!("%{}%", query);
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, name, file_path, thumbnail_path, category, watermark_type,
|
||||
file_size, width, height, description, tags, is_active,
|
||||
created_at, updated_at
|
||||
FROM watermark_templates
|
||||
WHERE (name LIKE ?1 OR description LIKE ?1 OR tags LIKE ?1)
|
||||
AND is_active = 1
|
||||
ORDER BY created_at DESC"
|
||||
)?;
|
||||
|
||||
let template_iter = stmt.query_map([search_pattern], |row| {
|
||||
Self::row_to_template(row)
|
||||
})?;
|
||||
|
||||
let mut templates = Vec::new();
|
||||
for template in template_iter {
|
||||
templates.push(template?);
|
||||
}
|
||||
|
||||
Ok(templates)
|
||||
}
|
||||
|
||||
/// 更新水印模板
|
||||
pub fn update(&self, template: &WatermarkTemplate) -> Result<()> {
|
||||
let conn = self.database.get_connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
let rows_affected = conn.execute(
|
||||
"UPDATE watermark_templates SET
|
||||
name = ?2, file_path = ?3, thumbnail_path = ?4, category = ?5,
|
||||
watermark_type = ?6, file_size = ?7, width = ?8, height = ?9,
|
||||
description = ?10, tags = ?11, is_active = ?12, updated_at = ?13
|
||||
WHERE id = ?1",
|
||||
params![
|
||||
template.id,
|
||||
template.name,
|
||||
template.file_path,
|
||||
template.thumbnail_path,
|
||||
serde_json::to_string(&template.category)?,
|
||||
serde_json::to_string(&template.watermark_type)?,
|
||||
template.file_size as i64,
|
||||
template.width.map(|w| w as i32),
|
||||
template.height.map(|h| h as i32),
|
||||
template.description,
|
||||
serde_json::to_string(&template.tags)?,
|
||||
template.is_active,
|
||||
template.updated_at.format("%Y-%m-%d %H:%M:%S").to_string(),
|
||||
],
|
||||
)?;
|
||||
|
||||
if rows_affected == 0 {
|
||||
return Err(anyhow!("水印模板不存在: {}", template.id));
|
||||
}
|
||||
|
||||
info!(template_id = %template.id, "水印模板更新成功");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 删除水印模板
|
||||
pub fn delete(&self, id: &str) -> Result<()> {
|
||||
let conn = self.database.get_connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
let rows_affected = conn.execute(
|
||||
"DELETE FROM watermark_templates WHERE id = ?1",
|
||||
[id],
|
||||
)?;
|
||||
|
||||
if rows_affected == 0 {
|
||||
return Err(anyhow!("水印模板不存在: {}", id));
|
||||
}
|
||||
|
||||
info!(template_id = %id, "水印模板删除成功");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 软删除水印模板(设置为非活跃状态)
|
||||
pub fn soft_delete(&self, id: &str) -> Result<()> {
|
||||
let conn = self.database.get_connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
let rows_affected = conn.execute(
|
||||
"UPDATE watermark_templates SET is_active = 0, updated_at = datetime('now') WHERE id = ?1",
|
||||
[id],
|
||||
)?;
|
||||
|
||||
if rows_affected == 0 {
|
||||
return Err(anyhow!("水印模板不存在: {}", id));
|
||||
}
|
||||
|
||||
info!(template_id = %id, "水印模板软删除成功");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取模板统计信息
|
||||
pub fn get_stats(&self) -> Result<serde_json::Value> {
|
||||
let conn = self.database.get_connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
// 总数统计
|
||||
let total_count: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM watermark_templates WHERE is_active = 1",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
|
||||
// 按分类统计
|
||||
let mut category_stats = std::collections::HashMap::new();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT category, COUNT(*) FROM watermark_templates
|
||||
WHERE is_active = 1 GROUP BY category"
|
||||
)?;
|
||||
|
||||
let category_iter = stmt.query_map([], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
|
||||
})?;
|
||||
|
||||
for result in category_iter {
|
||||
let (category, count) = result?;
|
||||
category_stats.insert(category, count);
|
||||
}
|
||||
|
||||
// 按类型统计
|
||||
let mut type_stats = std::collections::HashMap::new();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT watermark_type, COUNT(*) FROM watermark_templates
|
||||
WHERE is_active = 1 GROUP BY watermark_type"
|
||||
)?;
|
||||
|
||||
let type_iter = stmt.query_map([], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
|
||||
})?;
|
||||
|
||||
for result in type_iter {
|
||||
let (watermark_type, count) = result?;
|
||||
type_stats.insert(watermark_type, count);
|
||||
}
|
||||
|
||||
// 总文件大小
|
||||
let total_size: i64 = conn.query_row(
|
||||
"SELECT COALESCE(SUM(file_size), 0) FROM watermark_templates WHERE is_active = 1",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"total_templates": total_count,
|
||||
"by_category": category_stats,
|
||||
"by_type": type_stats,
|
||||
"total_size": total_size
|
||||
}))
|
||||
}
|
||||
|
||||
/// 检查模板名称是否已存在
|
||||
pub fn name_exists(&self, name: &str, exclude_id: Option<&str>) -> Result<bool> {
|
||||
let conn = self.database.get_connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
let count: i64 = if let Some(id) = exclude_id {
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM watermark_templates WHERE name = ?1 AND id != ?2 AND is_active = 1",
|
||||
[name, id],
|
||||
|row| row.get(0)
|
||||
)?
|
||||
} else {
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM watermark_templates WHERE name = ?1 AND is_active = 1",
|
||||
[name],
|
||||
|row| row.get(0)
|
||||
)?
|
||||
};
|
||||
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
/// 将数据库行转换为WatermarkTemplate对象
|
||||
fn row_to_template(row: &Row) -> rusqlite::Result<WatermarkTemplate> {
|
||||
let category_str: String = row.get("category")?;
|
||||
let category: WatermarkCategory = serde_json::from_str(&category_str)
|
||||
.map_err(|e| rusqlite::Error::InvalidColumnType(
|
||||
row.as_ref().column_index("category").unwrap(),
|
||||
format!("Invalid category: {}", e),
|
||||
rusqlite::types::Type::Text
|
||||
))?;
|
||||
|
||||
let type_str: String = row.get("watermark_type")?;
|
||||
let watermark_type: WatermarkType = serde_json::from_str(&type_str)
|
||||
.map_err(|e| rusqlite::Error::InvalidColumnType(
|
||||
row.as_ref().column_index("watermark_type").unwrap(),
|
||||
format!("Invalid watermark_type: {}", e),
|
||||
rusqlite::types::Type::Text
|
||||
))?;
|
||||
|
||||
let tags_str: String = row.get("tags")?;
|
||||
let tags: Vec<String> = serde_json::from_str(&tags_str)
|
||||
.map_err(|e| rusqlite::Error::InvalidColumnType(
|
||||
row.as_ref().column_index("tags").unwrap(),
|
||||
format!("Invalid tags: {}", e),
|
||||
rusqlite::types::Type::Text
|
||||
))?;
|
||||
|
||||
let created_at_str: String = row.get("created_at")?;
|
||||
let created_at = chrono::DateTime::parse_from_str(&created_at_str, "%Y-%m-%d %H:%M:%S")
|
||||
.map_err(|e| rusqlite::Error::InvalidColumnType(
|
||||
row.as_ref().column_index("created_at").unwrap(),
|
||||
format!("Invalid created_at: {}", e),
|
||||
rusqlite::types::Type::Text
|
||||
))?
|
||||
.with_timezone(&chrono::Utc);
|
||||
|
||||
let updated_at_str: String = row.get("updated_at")?;
|
||||
let updated_at = chrono::DateTime::parse_from_str(&updated_at_str, "%Y-%m-%d %H:%M:%S")
|
||||
.map_err(|e| rusqlite::Error::InvalidColumnType(
|
||||
row.as_ref().column_index("updated_at").unwrap(),
|
||||
format!("Invalid updated_at: {}", e),
|
||||
rusqlite::types::Type::Text
|
||||
))?
|
||||
.with_timezone(&chrono::Utc);
|
||||
|
||||
Ok(WatermarkTemplate {
|
||||
id: row.get("id")?,
|
||||
name: row.get("name")?,
|
||||
file_path: row.get("file_path")?,
|
||||
thumbnail_path: row.get("thumbnail_path")?,
|
||||
category,
|
||||
watermark_type,
|
||||
file_size: row.get::<_, i64>("file_size")? as u64,
|
||||
width: row.get::<_, Option<i32>>("width")?.map(|w| w as u32),
|
||||
height: row.get::<_, Option<i32>>("height")?.map(|h| h as u32),
|
||||
description: row.get("description")?,
|
||||
tags,
|
||||
is_active: row.get("is_active")?,
|
||||
created_at,
|
||||
updated_at,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -871,6 +871,75 @@ impl Database {
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 创建水印模板表
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS watermark_templates (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
thumbnail_path TEXT,
|
||||
category TEXT NOT NULL,
|
||||
watermark_type TEXT NOT NULL,
|
||||
file_size INTEGER NOT NULL,
|
||||
width INTEGER,
|
||||
height INTEGER,
|
||||
description TEXT,
|
||||
tags TEXT,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 创建水印检测结果表
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS watermark_detection_results (
|
||||
id TEXT PRIMARY KEY,
|
||||
material_id TEXT NOT NULL,
|
||||
detection_method TEXT NOT NULL,
|
||||
detections TEXT NOT NULL,
|
||||
confidence_score REAL NOT NULL,
|
||||
processing_time_ms INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (material_id) REFERENCES materials (id) ON DELETE CASCADE
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 创建批量水印处理任务表
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS batch_watermark_tasks (
|
||||
task_id TEXT PRIMARY KEY,
|
||||
operation TEXT NOT NULL,
|
||||
material_ids TEXT NOT NULL,
|
||||
config TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'Pending',
|
||||
progress TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
started_at DATETIME,
|
||||
completed_at DATETIME
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 创建水印处理结果表
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS watermark_processing_results (
|
||||
id TEXT PRIMARY KEY,
|
||||
material_id TEXT NOT NULL,
|
||||
operation TEXT NOT NULL,
|
||||
success INTEGER NOT NULL,
|
||||
output_path TEXT,
|
||||
processing_time_ms INTEGER NOT NULL,
|
||||
error_message TEXT,
|
||||
metadata TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (material_id) REFERENCES materials (id) ON DELETE CASCADE
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 创建索引
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_projects_name ON projects (name)",
|
||||
@@ -1024,6 +1093,70 @@ impl Database {
|
||||
"CREATE INDEX IF NOT EXISTS idx_outfit_search_history_query_text ON outfit_search_history (query_text)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 创建水印模板表索引
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_watermark_templates_category ON watermark_templates (category)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_watermark_templates_type ON watermark_templates (watermark_type)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_watermark_templates_active ON watermark_templates (is_active)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 创建水印检测结果表索引
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_watermark_detection_results_material_id ON watermark_detection_results (material_id)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_watermark_detection_results_method ON watermark_detection_results (detection_method)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_watermark_detection_results_confidence ON watermark_detection_results (confidence_score)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 创建批量水印处理任务表索引
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_batch_watermark_tasks_status ON batch_watermark_tasks (status)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_batch_watermark_tasks_operation ON batch_watermark_tasks (operation)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_batch_watermark_tasks_created_at ON batch_watermark_tasks (created_at)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 创建水印处理结果表索引
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_watermark_processing_results_material_id ON watermark_processing_results (material_id)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_watermark_processing_results_operation ON watermark_processing_results (operation)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_watermark_processing_results_success ON watermark_processing_results (success)",
|
||||
[],
|
||||
)?;
|
||||
// 添加新字段(如果不存在)- 数据库迁移
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE template_materials ADD COLUMN file_exists BOOLEAN DEFAULT FALSE",
|
||||
|
||||
319
apps/desktop/src-tauri/src/infrastructure/ffmpeg_watermark.rs
Normal file
319
apps/desktop/src-tauri/src/infrastructure/ffmpeg_watermark.rs
Normal file
@@ -0,0 +1,319 @@
|
||||
use anyhow::{Result, anyhow};
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use tracing::{info, debug, error};
|
||||
|
||||
/// FFmpeg水印处理工具类
|
||||
/// 专门用于水印相关的FFmpeg操作
|
||||
pub struct FFmpegWatermark;
|
||||
|
||||
impl FFmpegWatermark {
|
||||
/// 创建隐藏窗口的命令
|
||||
fn create_hidden_command(program: &str) -> Command {
|
||||
let mut cmd = Command::new(program);
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
|
||||
}
|
||||
|
||||
cmd
|
||||
}
|
||||
|
||||
/// 执行FFmpeg命令
|
||||
pub fn execute_command(args: &[&str]) -> Result<()> {
|
||||
debug!("执行FFmpeg命令: {:?}", args);
|
||||
|
||||
let output = Self::create_hidden_command("ffmpeg")
|
||||
.args(args)
|
||||
.output()
|
||||
.map_err(|e| anyhow!("FFmpeg执行失败: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
error!("FFmpeg命令失败: {}", stderr);
|
||||
return Err(anyhow!("FFmpeg命令执行失败: {}", stderr));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 提取视频帧到指定时间戳
|
||||
pub fn extract_frame_at_timestamp(
|
||||
input_path: &str,
|
||||
timestamp: f64,
|
||||
output_path: &str,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<()> {
|
||||
if !Path::new(input_path).exists() {
|
||||
return Err(anyhow!("输入文件不存在: {}", input_path));
|
||||
}
|
||||
|
||||
// 确保输出目录存在
|
||||
if let Some(parent) = Path::new(output_path).parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let timestamp_str = timestamp.to_string();
|
||||
let scale_filter = format!("scale={}:{}", width, height);
|
||||
|
||||
let args = vec![
|
||||
"-hide_banner",
|
||||
"-loglevel", "error",
|
||||
"-ss", ×tamp_str,
|
||||
"-i", input_path,
|
||||
"-vframes", "1",
|
||||
"-vf", &scale_filter,
|
||||
"-y", output_path,
|
||||
];
|
||||
|
||||
Self::execute_command(&args)?;
|
||||
|
||||
// 验证输出文件是否创建成功
|
||||
if !Path::new(output_path).exists() {
|
||||
return Err(anyhow!("帧提取失败,输出文件不存在"));
|
||||
}
|
||||
|
||||
info!("成功提取帧: {} -> {}", input_path, output_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取视频信息
|
||||
pub fn get_video_info(input_path: &str) -> Result<VideoInfo> {
|
||||
if !Path::new(input_path).exists() {
|
||||
return Err(anyhow!("输入文件不存在: {}", input_path));
|
||||
}
|
||||
|
||||
let output = Self::create_hidden_command("ffprobe")
|
||||
.args(&[
|
||||
"-hide_banner",
|
||||
"-loglevel", "error",
|
||||
"-select_streams", "v:0",
|
||||
"-show_entries", "stream=width,height,duration,nb_frames",
|
||||
"-of", "csv=p=0",
|
||||
input_path,
|
||||
])
|
||||
.output()
|
||||
.map_err(|e| anyhow!("ffprobe执行失败: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow!("ffprobe命令失败: {}", stderr));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let parts: Vec<&str> = stdout.trim().split(',').collect();
|
||||
|
||||
if parts.len() < 3 {
|
||||
return Err(anyhow!("无法解析视频信息"));
|
||||
}
|
||||
|
||||
let width = parts[0].parse::<u32>().ok();
|
||||
let height = parts[1].parse::<u32>().ok();
|
||||
let duration = parts[2].parse::<f64>().unwrap_or(0.0);
|
||||
let frame_count = if parts.len() > 3 {
|
||||
parts[3].parse::<u32>().ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(VideoInfo {
|
||||
width,
|
||||
height,
|
||||
duration,
|
||||
frame_count,
|
||||
})
|
||||
}
|
||||
|
||||
/// 应用视频滤镜
|
||||
pub fn apply_video_filter(
|
||||
input_path: &str,
|
||||
output_path: &str,
|
||||
filter: &str,
|
||||
quality_args: &[&str],
|
||||
) -> Result<()> {
|
||||
if !Path::new(input_path).exists() {
|
||||
return Err(anyhow!("输入文件不存在: {}", input_path));
|
||||
}
|
||||
|
||||
// 确保输出目录存在
|
||||
if let Some(parent) = Path::new(output_path).parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let mut args = vec![
|
||||
"-hide_banner",
|
||||
"-loglevel", "error",
|
||||
"-i", input_path,
|
||||
"-vf", filter,
|
||||
];
|
||||
|
||||
// 添加质量参数
|
||||
args.extend_from_slice(quality_args);
|
||||
args.extend_from_slice(&["-c:a", "copy", "-y", output_path]);
|
||||
|
||||
Self::execute_command(&args)?;
|
||||
|
||||
// 验证输出文件
|
||||
if !Path::new(output_path).exists() {
|
||||
return Err(anyhow!("视频处理失败,输出文件不存在"));
|
||||
}
|
||||
|
||||
info!("成功应用视频滤镜: {} -> {}", input_path, output_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 应用复杂滤镜(支持多输入)
|
||||
pub fn apply_complex_filter(
|
||||
input_paths: &[&str],
|
||||
output_path: &str,
|
||||
filter_complex: &str,
|
||||
quality_args: &[&str],
|
||||
) -> Result<()> {
|
||||
// 验证所有输入文件存在
|
||||
for input_path in input_paths {
|
||||
if !Path::new(input_path).exists() {
|
||||
return Err(anyhow!("输入文件不存在: {}", input_path));
|
||||
}
|
||||
}
|
||||
|
||||
// 确保输出目录存在
|
||||
if let Some(parent) = Path::new(output_path).parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let mut args = vec!["-hide_banner", "-loglevel", "error"];
|
||||
|
||||
// 添加所有输入文件
|
||||
for input_path in input_paths {
|
||||
args.extend_from_slice(&["-i", input_path]);
|
||||
}
|
||||
|
||||
// 添加复杂滤镜
|
||||
args.extend_from_slice(&["-filter_complex", filter_complex]);
|
||||
|
||||
// 添加质量参数
|
||||
args.extend_from_slice(quality_args);
|
||||
args.extend_from_slice(&["-c:a", "copy", "-y", output_path]);
|
||||
|
||||
Self::execute_command(&args)?;
|
||||
|
||||
// 验证输出文件
|
||||
if !Path::new(output_path).exists() {
|
||||
return Err(anyhow!("视频处理失败,输出文件不存在"));
|
||||
}
|
||||
|
||||
info!("成功应用复杂滤镜: {:?} -> {}", input_paths, output_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 处理图片
|
||||
pub fn process_image(
|
||||
input_path: &str,
|
||||
output_path: &str,
|
||||
filter: &str,
|
||||
) -> Result<()> {
|
||||
if !Path::new(input_path).exists() {
|
||||
return Err(anyhow!("输入文件不存在: {}", input_path));
|
||||
}
|
||||
|
||||
// 确保输出目录存在
|
||||
if let Some(parent) = Path::new(output_path).parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let args = vec![
|
||||
"-hide_banner",
|
||||
"-loglevel", "error",
|
||||
"-i", input_path,
|
||||
"-vf", filter,
|
||||
"-y", output_path,
|
||||
];
|
||||
|
||||
Self::execute_command(&args)?;
|
||||
|
||||
// 验证输出文件
|
||||
if !Path::new(output_path).exists() {
|
||||
return Err(anyhow!("图片处理失败,输出文件不存在"));
|
||||
}
|
||||
|
||||
info!("成功处理图片: {} -> {}", input_path, output_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 处理带多个输入的图片(如添加水印)
|
||||
pub fn process_image_with_overlay(
|
||||
input_paths: &[&str],
|
||||
output_path: &str,
|
||||
filter_complex: &str,
|
||||
) -> Result<()> {
|
||||
// 验证所有输入文件存在
|
||||
for input_path in input_paths {
|
||||
if !Path::new(input_path).exists() {
|
||||
return Err(anyhow!("输入文件不存在: {}", input_path));
|
||||
}
|
||||
}
|
||||
|
||||
// 确保输出目录存在
|
||||
if let Some(parent) = Path::new(output_path).parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let mut args = vec!["-hide_banner", "-loglevel", "error"];
|
||||
|
||||
// 添加所有输入文件
|
||||
for input_path in input_paths {
|
||||
args.extend_from_slice(&["-i", input_path]);
|
||||
}
|
||||
|
||||
// 添加复杂滤镜
|
||||
args.extend_from_slice(&["-filter_complex", filter_complex]);
|
||||
args.extend_from_slice(&["-y", output_path]);
|
||||
|
||||
Self::execute_command(&args)?;
|
||||
|
||||
// 验证输出文件
|
||||
if !Path::new(output_path).exists() {
|
||||
return Err(anyhow!("图片处理失败,输出文件不存在"));
|
||||
}
|
||||
|
||||
info!("成功处理图片叠加: {:?} -> {}", input_paths, output_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 检查FFmpeg是否可用
|
||||
pub fn is_available() -> bool {
|
||||
Self::create_hidden_command("ffmpeg")
|
||||
.arg("-version")
|
||||
.output()
|
||||
.map(|output| output.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 获取FFmpeg版本信息
|
||||
pub fn get_version() -> Result<String> {
|
||||
let output = Self::create_hidden_command("ffmpeg")
|
||||
.arg("-version")
|
||||
.output()
|
||||
.map_err(|e| anyhow!("获取FFmpeg版本失败: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(anyhow!("FFmpeg不可用"));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let first_line = stdout.lines().next().unwrap_or("Unknown version");
|
||||
Ok(first_line.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// 视频信息结构
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VideoInfo {
|
||||
pub width: Option<u32>,
|
||||
pub height: Option<u32>,
|
||||
pub duration: f64,
|
||||
pub frame_count: Option<u32>,
|
||||
}
|
||||
@@ -7,6 +7,7 @@ pub mod filename_utils;
|
||||
pub mod performance;
|
||||
pub mod event_bus;
|
||||
pub mod ffmpeg;
|
||||
pub mod ffmpeg_watermark;
|
||||
pub mod monitoring;
|
||||
pub mod logging;
|
||||
pub mod gemini_service;
|
||||
|
||||
@@ -334,7 +334,21 @@ pub fn run() {
|
||||
commands::markdown_commands::find_markdown_node_at_position,
|
||||
commands::markdown_commands::extract_markdown_outline,
|
||||
commands::markdown_commands::extract_markdown_links,
|
||||
commands::markdown_commands::validate_markdown
|
||||
commands::markdown_commands::validate_markdown,
|
||||
// 水印处理命令
|
||||
commands::watermark_commands::detect_watermarks_in_video,
|
||||
commands::watermark_commands::detect_watermarks_in_image,
|
||||
commands::watermark_commands::remove_watermarks_from_video,
|
||||
commands::watermark_commands::remove_watermarks_from_image,
|
||||
commands::watermark_commands::add_watermark_to_video,
|
||||
commands::watermark_commands::add_watermark_to_image,
|
||||
commands::watermark_commands::start_batch_watermark_task,
|
||||
commands::watermark_commands::get_watermark_templates,
|
||||
commands::watermark_commands::upload_watermark_template,
|
||||
commands::watermark_commands::delete_watermark_template,
|
||||
commands::watermark_commands::get_batch_task_status,
|
||||
commands::watermark_commands::get_watermark_template_thumbnail,
|
||||
commands::watermark_commands::cancel_batch_task
|
||||
])
|
||||
.setup(|app| {
|
||||
// 初始化日志系统
|
||||
|
||||
@@ -25,3 +25,4 @@ pub mod markdown_commands;
|
||||
pub mod rag_grounding_commands;
|
||||
pub mod image_download_commands;
|
||||
pub mod conversation_commands;
|
||||
pub mod watermark_commands;
|
||||
|
||||
@@ -0,0 +1,544 @@
|
||||
use tauri::{command, State};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::collections::HashMap;
|
||||
use tracing::{info, error, warn};
|
||||
use lazy_static::lazy_static;
|
||||
|
||||
use crate::data::models::watermark::{
|
||||
WatermarkDetectionConfig, WatermarkRemovalConfig, WatermarkConfig,
|
||||
WatermarkTemplate, BatchWatermarkTask, WatermarkOperation,
|
||||
BatchTaskStatus, BatchProgress, WatermarkDetectionResult,
|
||||
WatermarkProcessingResult
|
||||
};
|
||||
use crate::business::services::{
|
||||
watermark_detection_service::WatermarkDetectionService,
|
||||
watermark_removal_service::WatermarkRemovalService,
|
||||
watermark_addition_service::WatermarkAdditionService,
|
||||
batch_watermark_processor::BatchWatermarkProcessor,
|
||||
task_manager::TASK_MANAGER,
|
||||
};
|
||||
|
||||
// 全局模板存储
|
||||
lazy_static! {
|
||||
static ref TEMPLATE_STORAGE: Mutex<HashMap<String, WatermarkTemplate>> = {
|
||||
let mut templates = HashMap::new();
|
||||
|
||||
// 添加一些默认模板
|
||||
let default_templates = create_default_templates();
|
||||
for template in default_templates {
|
||||
templates.insert(template.id.clone(), template);
|
||||
}
|
||||
|
||||
Mutex::new(templates)
|
||||
};
|
||||
}
|
||||
|
||||
// 创建默认模板
|
||||
fn create_default_templates() -> Vec<WatermarkTemplate> {
|
||||
use crate::data::models::watermark::{WatermarkCategory, WatermarkType};
|
||||
|
||||
vec![
|
||||
WatermarkTemplate {
|
||||
id: "template_1".to_string(),
|
||||
name: "公司Logo".to_string(),
|
||||
file_path: "watermarks/logo.png".to_string(),
|
||||
thumbnail_path: Some("watermarks/thumbnails/logo.jpg".to_string()),
|
||||
category: WatermarkCategory::Logo,
|
||||
watermark_type: WatermarkType::Image,
|
||||
file_size: 2048,
|
||||
width: Some(200),
|
||||
height: Some(100),
|
||||
description: Some("公司标准Logo水印".to_string()),
|
||||
tags: vec!["logo".to_string(), "公司".to_string()],
|
||||
is_active: true,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
},
|
||||
WatermarkTemplate {
|
||||
id: "template_2".to_string(),
|
||||
name: "版权声明".to_string(),
|
||||
file_path: "watermarks/copyright.png".to_string(),
|
||||
thumbnail_path: Some("watermarks/thumbnails/copyright.jpg".to_string()),
|
||||
category: WatermarkCategory::Copyright,
|
||||
watermark_type: WatermarkType::Text,
|
||||
file_size: 1024,
|
||||
width: Some(300),
|
||||
height: Some(50),
|
||||
description: Some("标准版权声明水印".to_string()),
|
||||
tags: vec!["版权".to_string(), "声明".to_string()],
|
||||
is_active: true,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
},
|
||||
]
|
||||
}
|
||||
use crate::AppState;
|
||||
|
||||
/// 检测视频中的水印
|
||||
#[command]
|
||||
pub async fn detect_watermarks_in_video(
|
||||
state: State<'_, AppState>,
|
||||
material_id: String,
|
||||
video_path: String,
|
||||
config: WatermarkDetectionConfig,
|
||||
) -> Result<WatermarkDetectionResult, String> {
|
||||
info!(
|
||||
material_id = %material_id,
|
||||
video_path = %video_path,
|
||||
"Tauri命令: 检测视频水印"
|
||||
);
|
||||
|
||||
let repository = {
|
||||
let repo_guard = state.material_repository.lock().unwrap();
|
||||
repo_guard.as_ref()
|
||||
.ok_or_else(|| "Material repository not initialized".to_string())?
|
||||
.clone()
|
||||
};
|
||||
|
||||
WatermarkDetectionService::detect_watermarks_in_video(
|
||||
&material_id,
|
||||
&video_path,
|
||||
&config,
|
||||
Arc::new(repository.clone()),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!(error = %e, "检测视频水印失败");
|
||||
e.to_string()
|
||||
})
|
||||
}
|
||||
|
||||
/// 检测图片中的水印
|
||||
#[command]
|
||||
pub async fn detect_watermarks_in_image(
|
||||
material_id: String,
|
||||
image_path: String,
|
||||
config: WatermarkDetectionConfig,
|
||||
) -> Result<WatermarkDetectionResult, String> {
|
||||
info!(
|
||||
material_id = %material_id,
|
||||
image_path = %image_path,
|
||||
"Tauri命令: 检测图片水印"
|
||||
);
|
||||
|
||||
WatermarkDetectionService::detect_watermarks_in_image(
|
||||
&material_id,
|
||||
&image_path,
|
||||
&config,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!(error = %e, "检测图片水印失败");
|
||||
e.to_string()
|
||||
})
|
||||
}
|
||||
|
||||
/// 移除视频中的水印
|
||||
#[command]
|
||||
pub async fn remove_watermarks_from_video(
|
||||
state: State<'_, AppState>,
|
||||
material_id: String,
|
||||
input_path: String,
|
||||
output_path: String,
|
||||
config: WatermarkRemovalConfig,
|
||||
) -> Result<WatermarkProcessingResult, String> {
|
||||
info!(
|
||||
material_id = %material_id,
|
||||
input_path = %input_path,
|
||||
output_path = %output_path,
|
||||
"Tauri命令: 移除视频水印"
|
||||
);
|
||||
|
||||
let repository = {
|
||||
let repo_guard = state.material_repository.lock().unwrap();
|
||||
repo_guard.as_ref()
|
||||
.ok_or_else(|| "Material repository not initialized".to_string())?
|
||||
.clone()
|
||||
};
|
||||
|
||||
WatermarkRemovalService::remove_watermarks_from_video(
|
||||
&material_id,
|
||||
&input_path,
|
||||
&output_path,
|
||||
&config,
|
||||
Arc::new(repository.clone()),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!(error = %e, "移除视频水印失败");
|
||||
e.to_string()
|
||||
})
|
||||
}
|
||||
|
||||
/// 移除图片中的水印
|
||||
#[command]
|
||||
pub async fn remove_watermarks_from_image(
|
||||
material_id: String,
|
||||
input_path: String,
|
||||
output_path: String,
|
||||
config: WatermarkRemovalConfig,
|
||||
) -> Result<WatermarkProcessingResult, String> {
|
||||
info!(
|
||||
material_id = %material_id,
|
||||
input_path = %input_path,
|
||||
output_path = %output_path,
|
||||
"Tauri命令: 移除图片水印"
|
||||
);
|
||||
|
||||
WatermarkRemovalService::remove_watermarks_from_image(
|
||||
&material_id,
|
||||
&input_path,
|
||||
&output_path,
|
||||
&config,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!(error = %e, "移除图片水印失败");
|
||||
e.to_string()
|
||||
})
|
||||
}
|
||||
|
||||
/// 为视频添加水印
|
||||
#[command]
|
||||
pub async fn add_watermark_to_video(
|
||||
state: State<'_, AppState>,
|
||||
material_id: String,
|
||||
input_path: String,
|
||||
output_path: String,
|
||||
watermark_path: String,
|
||||
config: WatermarkConfig,
|
||||
) -> Result<WatermarkProcessingResult, String> {
|
||||
info!(
|
||||
material_id = %material_id,
|
||||
input_path = %input_path,
|
||||
output_path = %output_path,
|
||||
watermark_path = %watermark_path,
|
||||
"Tauri命令: 为视频添加水印"
|
||||
);
|
||||
|
||||
let repository = {
|
||||
let repo_guard = state.material_repository.lock().unwrap();
|
||||
repo_guard.as_ref()
|
||||
.ok_or_else(|| "Material repository not initialized".to_string())?
|
||||
.clone()
|
||||
};
|
||||
|
||||
WatermarkAdditionService::add_watermark_to_video(
|
||||
&material_id,
|
||||
&input_path,
|
||||
&output_path,
|
||||
&watermark_path,
|
||||
&config,
|
||||
Arc::new(repository.clone()),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!(error = %e, "为视频添加水印失败");
|
||||
e.to_string()
|
||||
})
|
||||
}
|
||||
|
||||
/// 为图片添加水印
|
||||
#[command]
|
||||
pub async fn add_watermark_to_image(
|
||||
material_id: String,
|
||||
input_path: String,
|
||||
output_path: String,
|
||||
watermark_path: String,
|
||||
config: WatermarkConfig,
|
||||
) -> Result<WatermarkProcessingResult, String> {
|
||||
info!(
|
||||
material_id = %material_id,
|
||||
input_path = %input_path,
|
||||
output_path = %output_path,
|
||||
watermark_path = %watermark_path,
|
||||
"Tauri命令: 为图片添加水印"
|
||||
);
|
||||
|
||||
WatermarkAdditionService::add_watermark_to_image(
|
||||
&material_id,
|
||||
&input_path,
|
||||
&output_path,
|
||||
&watermark_path,
|
||||
&config,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!(error = %e, "为图片添加水印失败");
|
||||
e.to_string()
|
||||
})
|
||||
}
|
||||
|
||||
/// 启动批量水印处理任务
|
||||
#[command]
|
||||
pub async fn start_batch_watermark_task(
|
||||
state: State<'_, AppState>,
|
||||
operation: WatermarkOperation,
|
||||
material_ids: Vec<String>,
|
||||
config: serde_json::Value,
|
||||
) -> Result<String, String> {
|
||||
info!(
|
||||
operation = ?operation,
|
||||
material_count = material_ids.len(),
|
||||
"Tauri命令: 启动批量水印处理任务"
|
||||
);
|
||||
|
||||
let task_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let task = BatchWatermarkTask {
|
||||
task_id: task_id.clone(),
|
||||
operation,
|
||||
material_ids: material_ids.clone(),
|
||||
config,
|
||||
status: BatchTaskStatus::Pending,
|
||||
progress: BatchProgress {
|
||||
total_items: material_ids.len() as u32,
|
||||
processed_items: 0,
|
||||
failed_items: 0,
|
||||
current_item: None,
|
||||
progress_percentage: 0.0,
|
||||
estimated_remaining_ms: None,
|
||||
errors: Vec::new(),
|
||||
detection_results: Vec::new(),
|
||||
processing_results: Vec::new(),
|
||||
},
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
started_at: None,
|
||||
completed_at: None,
|
||||
};
|
||||
|
||||
let repository = {
|
||||
let repo_guard = state.material_repository.lock().unwrap();
|
||||
repo_guard.as_ref()
|
||||
.ok_or_else(|| "Material repository not initialized".to_string())?
|
||||
.clone()
|
||||
};
|
||||
|
||||
// 在后台启动任务
|
||||
let task_id_clone = task_id.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = BatchWatermarkProcessor::start_batch_task(task, Arc::new(repository.clone())).await {
|
||||
error!(task_id = %task_id_clone, error = %e, "批量水印处理任务失败");
|
||||
}
|
||||
});
|
||||
|
||||
Ok(task_id)
|
||||
}
|
||||
|
||||
/// 获取水印模板列表
|
||||
#[command]
|
||||
pub async fn get_watermark_templates(
|
||||
category: Option<String>,
|
||||
watermark_type: Option<String>,
|
||||
) -> Result<Vec<WatermarkTemplate>, String> {
|
||||
info!(
|
||||
category = ?category,
|
||||
watermark_type = ?watermark_type,
|
||||
"Tauri命令: 获取水印模板列表"
|
||||
);
|
||||
|
||||
// 从内存存储获取模板
|
||||
let storage = TEMPLATE_STORAGE.lock().unwrap();
|
||||
let mut templates: Vec<WatermarkTemplate> = storage.values()
|
||||
.filter(|template| template.is_active)
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
// 根据分类过滤
|
||||
if let Some(cat) = category {
|
||||
templates.retain(|t| format!("{:?}", t.category).contains(&cat));
|
||||
}
|
||||
|
||||
// 根据类型过滤
|
||||
if let Some(wtype) = watermark_type {
|
||||
templates.retain(|t| format!("{:?}", t.watermark_type).contains(&wtype));
|
||||
}
|
||||
|
||||
info!(template_count = templates.len(), "返回水印模板列表");
|
||||
Ok(templates)
|
||||
}
|
||||
|
||||
/// 上传水印模板
|
||||
#[command]
|
||||
pub async fn upload_watermark_template(
|
||||
name: String,
|
||||
file_path: String,
|
||||
category: String,
|
||||
watermark_type: String,
|
||||
description: Option<String>,
|
||||
tags: Vec<String>,
|
||||
) -> Result<WatermarkTemplate, String> {
|
||||
info!(
|
||||
name = %name,
|
||||
file_path = %file_path,
|
||||
category = %category,
|
||||
watermark_type = %watermark_type,
|
||||
"Tauri命令: 上传水印模板"
|
||||
);
|
||||
|
||||
// 简单实现:创建模板记录
|
||||
// 在实际应用中,这里应该:
|
||||
// 1. 验证文件存在和格式
|
||||
// 2. 复制文件到模板目录
|
||||
// 3. 生成缩略图
|
||||
// 4. 保存到数据库
|
||||
|
||||
let template = WatermarkTemplate {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
name: name.clone(),
|
||||
file_path: format!("watermarks/templates/{}", file_path), // 模拟路径
|
||||
thumbnail_path: Some(format!("watermarks/thumbnails/{}.jpg", name)),
|
||||
category: match category.as_str() {
|
||||
"Logo" => crate::data::models::watermark::WatermarkCategory::Logo,
|
||||
"Copyright" => crate::data::models::watermark::WatermarkCategory::Copyright,
|
||||
"Signature" => crate::data::models::watermark::WatermarkCategory::Signature,
|
||||
"Decoration" => crate::data::models::watermark::WatermarkCategory::Decoration,
|
||||
"Custom" => crate::data::models::watermark::WatermarkCategory::Custom,
|
||||
_ => return Err(format!("无效的分类: {}", category)),
|
||||
},
|
||||
watermark_type: match watermark_type.as_str() {
|
||||
"Image" => crate::data::models::watermark::WatermarkType::Image,
|
||||
"Vector" => crate::data::models::watermark::WatermarkType::Vector,
|
||||
"Text" => crate::data::models::watermark::WatermarkType::Text,
|
||||
"Animated" => crate::data::models::watermark::WatermarkType::Animated,
|
||||
_ => return Err(format!("无效的水印类型: {}", watermark_type)),
|
||||
},
|
||||
file_size: 1024, // 模拟文件大小
|
||||
width: Some(200),
|
||||
height: Some(100),
|
||||
description,
|
||||
tags,
|
||||
is_active: true,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
// 将模板添加到存储中
|
||||
{
|
||||
let mut storage = TEMPLATE_STORAGE.lock().unwrap();
|
||||
storage.insert(template.id.clone(), template.clone());
|
||||
}
|
||||
|
||||
info!(template_id = %template.id, name = %template.name, "水印模板上传成功");
|
||||
Ok(template)
|
||||
}
|
||||
|
||||
/// 删除水印模板
|
||||
#[command]
|
||||
pub async fn delete_watermark_template(
|
||||
template_id: String,
|
||||
) -> Result<bool, String> {
|
||||
info!(
|
||||
template_id = %template_id,
|
||||
"Tauri命令: 删除水印模板"
|
||||
);
|
||||
|
||||
// 从存储中删除模板
|
||||
let mut storage = TEMPLATE_STORAGE.lock().unwrap();
|
||||
let removed = storage.remove(&template_id);
|
||||
|
||||
match removed {
|
||||
Some(_) => {
|
||||
info!(template_id = %template_id, "水印模板删除成功");
|
||||
Ok(true)
|
||||
}
|
||||
None => {
|
||||
warn!(template_id = %template_id, "要删除的模板不存在");
|
||||
Err(format!("模板不存在: {}", template_id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取批量任务状态
|
||||
#[command]
|
||||
pub async fn get_batch_task_status(
|
||||
task_id: String,
|
||||
) -> Result<BatchWatermarkTask, String> {
|
||||
info!(
|
||||
task_id = %task_id,
|
||||
"Tauri命令: 获取批量任务状态"
|
||||
);
|
||||
|
||||
match TASK_MANAGER.get_task(&task_id) {
|
||||
Some(task) => {
|
||||
info!(
|
||||
task_id = %task_id,
|
||||
status = ?task.status,
|
||||
progress = task.progress.progress_percentage,
|
||||
"返回任务状态"
|
||||
);
|
||||
Ok(task)
|
||||
}
|
||||
None => {
|
||||
warn!(task_id = %task_id, "任务不存在");
|
||||
Err(format!("任务不存在: {}", task_id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取水印模板缩略图
|
||||
#[command]
|
||||
pub async fn get_watermark_template_thumbnail(
|
||||
template_id: String,
|
||||
) -> Result<String, String> {
|
||||
info!(
|
||||
template_id = %template_id,
|
||||
"Tauri命令: 获取水印模板缩略图"
|
||||
);
|
||||
|
||||
let storage = TEMPLATE_STORAGE.lock().unwrap();
|
||||
|
||||
match storage.get(&template_id) {
|
||||
Some(template) => {
|
||||
// 模拟生成base64缩略图数据
|
||||
// 在实际应用中,这里应该读取实际的缩略图文件
|
||||
let mock_thumbnail = generate_mock_thumbnail(&template.name, &template.watermark_type);
|
||||
info!(template_id = %template_id, "返回模板缩略图");
|
||||
Ok(mock_thumbnail)
|
||||
}
|
||||
None => {
|
||||
warn!(template_id = %template_id, "模板不存在");
|
||||
Err(format!("模板不存在: {}", template_id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 生成模拟缩略图数据
|
||||
fn generate_mock_thumbnail(name: &str, watermark_type: &crate::data::models::watermark::WatermarkType) -> String {
|
||||
// 返回一个简单的彩色方块作为缩略图
|
||||
let (bg_color, text_color, type_name) = match watermark_type {
|
||||
crate::data::models::watermark::WatermarkType::Image => ("lightblue", "darkblue", "图片"),
|
||||
crate::data::models::watermark::WatermarkType::Text => ("lightpink", "purple", "文字"),
|
||||
crate::data::models::watermark::WatermarkType::Vector => ("lightgreen", "darkgreen", "矢量"),
|
||||
crate::data::models::watermark::WatermarkType::Animated => ("lightyellow", "orange", "动画"),
|
||||
};
|
||||
|
||||
let svg_content = format!(
|
||||
r#"<svg width="200" height="100" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="200" height="100" fill="{}"/>
|
||||
<text x="100" y="45" text-anchor="middle" fill="{}" font-family="Arial" font-size="14" font-weight="bold">{}</text>
|
||||
<text x="100" y="65" text-anchor="middle" fill="{}" font-family="Arial" font-size="10">{}水印</text>
|
||||
</svg>"#,
|
||||
bg_color, text_color, name, text_color, type_name
|
||||
);
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose};
|
||||
let encoded = general_purpose::STANDARD.encode(svg_content.as_bytes());
|
||||
format!("data:image/svg+xml;base64,{}", encoded)
|
||||
}
|
||||
|
||||
/// 取消批量任务
|
||||
#[command]
|
||||
pub async fn cancel_batch_task(
|
||||
task_id: String,
|
||||
) -> Result<bool, String> {
|
||||
info!(
|
||||
task_id = %task_id,
|
||||
"Tauri命令: 取消批量任务"
|
||||
);
|
||||
|
||||
// TODO: 实现任务取消逻辑
|
||||
Ok(true)
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import JsonParserTool from './pages/tools/JsonParserTool';
|
||||
import DebugPanelTool from './pages/tools/DebugPanelTool';
|
||||
import ChatTool from './pages/tools/ChatTool';
|
||||
import ChatTestPage from './pages/tools/ChatTestPage';
|
||||
import WatermarkTool from './pages/tools/WatermarkTool';
|
||||
|
||||
import Navigation from './components/Navigation';
|
||||
import { NotificationSystem, useNotifications } from './components/NotificationSystem';
|
||||
@@ -99,6 +100,7 @@ function App() {
|
||||
<Route path="/tools/debug-panel" element={<DebugPanelTool />} />
|
||||
<Route path="/tools/ai-chat" element={<ChatTool />} />
|
||||
<Route path="/tools/chat-test" element={<ChatTestPage />} />
|
||||
<Route path="/tools/watermark" element={<WatermarkTool />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
114
apps/desktop/src/components/WatermarkTemplateThumbnail.tsx
Normal file
114
apps/desktop/src/components/WatermarkTemplateThumbnail.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { Loader2, ImageIcon } from 'lucide-react';
|
||||
|
||||
interface WatermarkTemplateThumbnailProps {
|
||||
templateId: string;
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
className?: string;
|
||||
thumbnailCache?: Map<string, string>;
|
||||
setThumbnailCache?: (cache: Map<string, string>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 水印模板缩略图组件
|
||||
* 参考其他页面的缩略图实现模式
|
||||
*/
|
||||
export const WatermarkTemplateThumbnail: React.FC<WatermarkTemplateThumbnailProps> = ({
|
||||
templateId,
|
||||
size = 'medium',
|
||||
className = '',
|
||||
thumbnailCache = new Map(),
|
||||
setThumbnailCache = () => {},
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
// 根据size确定尺寸
|
||||
const getSizeClasses = () => {
|
||||
switch (size) {
|
||||
case 'small':
|
||||
return 'w-12 h-12';
|
||||
case 'medium':
|
||||
return 'w-16 h-16';
|
||||
case 'large':
|
||||
return 'w-24 h-24';
|
||||
default:
|
||||
return 'w-16 h-16';
|
||||
}
|
||||
};
|
||||
|
||||
const getIconSize = () => {
|
||||
switch (size) {
|
||||
case 'small':
|
||||
return 'w-3 h-3';
|
||||
case 'medium':
|
||||
return 'w-4 h-4';
|
||||
case 'large':
|
||||
return 'w-6 h-6';
|
||||
default:
|
||||
return 'w-4 h-4';
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!templateId) return;
|
||||
|
||||
const loadThumbnail = async () => {
|
||||
// 检查缓存
|
||||
if (thumbnailCache.has(templateId)) {
|
||||
const cachedUrl = thumbnailCache.get(templateId);
|
||||
setThumbnailUrl(cachedUrl || null);
|
||||
return;
|
||||
}
|
||||
|
||||
// 加载缩略图
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
|
||||
try {
|
||||
console.log('获取水印模板缩略图:', templateId);
|
||||
const dataUrl = await invoke<string>('get_watermark_template_thumbnail', {
|
||||
templateId: templateId
|
||||
});
|
||||
console.log('获取缩略图成功');
|
||||
setThumbnailUrl(dataUrl);
|
||||
|
||||
// 更新缓存
|
||||
const newCache = new Map(thumbnailCache);
|
||||
newCache.set(templateId, dataUrl);
|
||||
setThumbnailCache(newCache);
|
||||
} catch (error) {
|
||||
console.error('获取缩略图失败:', error);
|
||||
setError(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadThumbnail();
|
||||
}, [templateId, thumbnailCache, setThumbnailCache]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${getSizeClasses()} bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0 overflow-hidden ${className}`}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className={`${getIconSize()} animate-spin text-blue-600`} />
|
||||
) : thumbnailUrl && !error ? (
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt="水印模板缩略图"
|
||||
className="w-full h-full object-cover rounded-lg"
|
||||
onError={() => {
|
||||
setError(true);
|
||||
setThumbnailUrl(null);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ImageIcon className={`${getIconSize()} text-gray-400`} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
1016
apps/desktop/src/components/WatermarkToolDialog.tsx
Normal file
1016
apps/desktop/src/components/WatermarkToolDialog.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,8 @@ import {
|
||||
Wrench,
|
||||
Database,
|
||||
FileSearch,
|
||||
MessageCircle
|
||||
MessageCircle,
|
||||
Droplets
|
||||
} from 'lucide-react';
|
||||
import { Tool, ToolCategory, ToolStatus } from '../types/tool';
|
||||
|
||||
@@ -69,6 +70,21 @@ export const TOOLS_DATA: Tool[] = [
|
||||
isPopular: true,
|
||||
version: '1.0.0',
|
||||
lastUpdated: '2024-01-21'
|
||||
},
|
||||
{
|
||||
id: 'watermark-tool',
|
||||
name: '水印处理工具',
|
||||
description: '专业的视频水印检测、移除和添加工具,支持批量处理和多种水印类型',
|
||||
longDescription: '强大的水印处理工具集,提供智能水印检测、精确移除和自定义添加功能。支持视频和图片格式,提供多种移除算法(AI修复、模糊处理、裁剪等)和丰富的水印样式选择。',
|
||||
icon: Droplets,
|
||||
route: '/tools/watermark',
|
||||
category: ToolCategory.FILE_PROCESSING,
|
||||
status: ToolStatus.STABLE,
|
||||
tags: ['水印检测', '水印移除', '水印添加', '批量处理', '视频处理'],
|
||||
isNew: true,
|
||||
isPopular: true,
|
||||
version: '1.0.0',
|
||||
lastUpdated: '2024-01-23'
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
241
apps/desktop/src/pages/tools/WatermarkTool.tsx
Normal file
241
apps/desktop/src/pages/tools/WatermarkTool.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ArrowLeft, Droplets, Upload, Settings } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { Material } from '../../types/material';
|
||||
import { WatermarkToolDialog } from '../../components/WatermarkToolDialog';
|
||||
import { LoadingSpinner } from '../../components/LoadingSpinner';
|
||||
|
||||
/**
|
||||
* 水印工具页面
|
||||
* 提供水印检测、移除和添加功能
|
||||
*/
|
||||
const WatermarkTool: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 状态管理
|
||||
const [materials, setMaterials] = useState<Material[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedMaterials, setSelectedMaterials] = useState<string[]>([]);
|
||||
const [showWatermarkDialog, setShowWatermarkDialog] = useState(false);
|
||||
|
||||
// 加载素材列表
|
||||
useEffect(() => {
|
||||
loadMaterials();
|
||||
}, []);
|
||||
|
||||
const loadMaterials = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const result = await invoke<Material[]>('get_all_materials');
|
||||
setMaterials(result);
|
||||
} catch (error) {
|
||||
console.error('加载素材失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理素材选择
|
||||
const handleMaterialSelect = (materialId: string) => {
|
||||
setSelectedMaterials(prev => {
|
||||
if (prev.includes(materialId)) {
|
||||
return prev.filter(id => id !== materialId);
|
||||
} else {
|
||||
return [...prev, materialId];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 全选/取消全选
|
||||
const handleSelectAll = () => {
|
||||
if (selectedMaterials.length === materials.length) {
|
||||
setSelectedMaterials([]);
|
||||
} else {
|
||||
setSelectedMaterials(materials.map(m => m.id));
|
||||
}
|
||||
};
|
||||
|
||||
// 打开水印工具对话框
|
||||
const handleOpenWatermarkTool = () => {
|
||||
if (selectedMaterials.length === 0) {
|
||||
alert('请先选择要处理的素材');
|
||||
return;
|
||||
}
|
||||
setShowWatermarkDialog(true);
|
||||
};
|
||||
|
||||
|
||||
|
||||
// 格式化文件大小
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
// 格式化时长
|
||||
const formatDuration = (seconds: number) => {
|
||||
if (!seconds) return '--';
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// 获取素材的时长信息
|
||||
const getMaterialDuration = (material: Material): number | null => {
|
||||
if (material.metadata === 'None') return null;
|
||||
if ('Video' in material.metadata) return material.metadata.Video.duration;
|
||||
if ('Audio' in material.metadata) return material.metadata.Audio.duration;
|
||||
return null;
|
||||
};
|
||||
|
||||
// 获取素材的尺寸信息
|
||||
const getMaterialDimensions = (material: Material): { width: number; height: number } | null => {
|
||||
if (material.metadata === 'None') return null;
|
||||
if ('Video' in material.metadata) {
|
||||
return { width: material.metadata.Video.width, height: material.metadata.Video.height };
|
||||
}
|
||||
if ('Image' in material.metadata) {
|
||||
return { width: material.metadata.Image.width, height: material.metadata.Image.height };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 页面头部 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/tools')}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center shadow-lg">
|
||||
<Droplets className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">水印处理工具</h1>
|
||||
<p className="text-gray-600">检测、移除和添加视频水印</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleOpenWatermarkTool}
|
||||
disabled={selectedMaterials.length === 0}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
水印处理
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作栏 */}
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedMaterials.length === materials.length && materials.length > 0}
|
||||
onChange={handleSelectAll}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
全选 ({selectedMaterials.length}/{materials.length})
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-600">
|
||||
共 {materials.length} 个素材
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 素材列表 */}
|
||||
<div className="card">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<LoadingSpinner size="large" />
|
||||
</div>
|
||||
) : materials.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Upload className="w-8 h-8 text-gray-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">暂无素材</h3>
|
||||
<p className="text-gray-600">请先导入一些素材文件</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{materials.map((material) => (
|
||||
<div
|
||||
key={material.id}
|
||||
className={`p-4 hover:bg-gray-50 transition-colors ${selectedMaterials.includes(material.id) ? 'bg-blue-50' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedMaterials.includes(material.id)}
|
||||
onChange={() => handleMaterialSelect(material.id)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="font-medium text-gray-900 truncate">{material.name}</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6 text-sm text-gray-600">
|
||||
<span>类型: {material.material_type}</span>
|
||||
{material.file_size && (
|
||||
<span>大小: {formatFileSize(material.file_size)}</span>
|
||||
)}
|
||||
{(() => {
|
||||
const duration = getMaterialDuration(material);
|
||||
return duration && (
|
||||
<span>时长: {formatDuration(duration)}</span>
|
||||
);
|
||||
})()}
|
||||
{(() => {
|
||||
const dimensions = getMaterialDimensions(material);
|
||||
return dimensions && (
|
||||
<span>分辨率: {dimensions.width}×{dimensions.height}</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<div className="mt-1 text-xs text-gray-500 truncate">
|
||||
{material.original_path}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 水印工具对话框 */}
|
||||
{showWatermarkDialog && (
|
||||
<WatermarkToolDialog
|
||||
isOpen={showWatermarkDialog}
|
||||
onClose={() => setShowWatermarkDialog(false)}
|
||||
selectedMaterials={selectedMaterials.map(id => materials.find(m => m.id === id)!).filter(Boolean)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WatermarkTool;
|
||||
322
apps/desktop/src/types/watermark.ts
Normal file
322
apps/desktop/src/types/watermark.ts
Normal file
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* 水印相关类型定义
|
||||
* 与Rust后端的数据模型保持一致
|
||||
*/
|
||||
|
||||
export interface WatermarkTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
file_path: string;
|
||||
thumbnail_path?: string;
|
||||
category: WatermarkCategory;
|
||||
watermark_type: WatermarkType;
|
||||
file_size: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
description?: string;
|
||||
tags: string[];
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export type WatermarkType = 'Image' | 'Vector' | 'Text' | 'Animated';
|
||||
|
||||
export type WatermarkCategory = 'Logo' | 'Copyright' | 'Signature' | 'Decoration' | 'Custom';
|
||||
|
||||
export interface WatermarkDetectionResult {
|
||||
id: string;
|
||||
material_id: string;
|
||||
detection_method: DetectionMethod;
|
||||
detections: WatermarkDetection[];
|
||||
confidence_score: number;
|
||||
processing_time_ms: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface WatermarkDetection {
|
||||
region: BoundingBox;
|
||||
confidence: number;
|
||||
watermark_type?: WatermarkType;
|
||||
template_id?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface BoundingBox {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export type DetectionMethod =
|
||||
| 'TemplateMatching'
|
||||
| 'EdgeDetection'
|
||||
| 'FrequencyAnalysis'
|
||||
| 'TransparencyDetection'
|
||||
| 'Combined';
|
||||
|
||||
export interface WatermarkConfig {
|
||||
watermark_type: WatermarkType;
|
||||
position: WatermarkPosition;
|
||||
opacity: number; // 0.0-1.0
|
||||
scale: number;
|
||||
rotation: number; // 角度
|
||||
animation?: WatermarkAnimation;
|
||||
blend_mode: BlendMode;
|
||||
quality_level: QualityLevel;
|
||||
}
|
||||
|
||||
export type WatermarkPosition =
|
||||
| 'TopLeft'
|
||||
| 'TopCenter'
|
||||
| 'TopRight'
|
||||
| 'MiddleLeft'
|
||||
| 'Center'
|
||||
| 'MiddleRight'
|
||||
| 'BottomLeft'
|
||||
| 'BottomCenter'
|
||||
| 'BottomRight'
|
||||
| { Custom: { x: number; y: number } }
|
||||
| { Dynamic: DynamicPositionRule };
|
||||
|
||||
export interface DynamicPositionRule {
|
||||
avoid_faces: boolean;
|
||||
avoid_text: boolean;
|
||||
follow_motion: boolean;
|
||||
corner_preference: Corner[];
|
||||
min_distance_from_edge: number;
|
||||
}
|
||||
|
||||
export type Corner = 'TopLeft' | 'TopRight' | 'BottomLeft' | 'BottomRight';
|
||||
|
||||
export interface WatermarkAnimation {
|
||||
animation_type: AnimationType;
|
||||
duration_ms: number;
|
||||
loop_count?: number; // undefined表示无限循环
|
||||
easing: EasingFunction;
|
||||
}
|
||||
|
||||
export type AnimationType =
|
||||
| 'FadeIn'
|
||||
| 'FadeOut'
|
||||
| 'SlideIn'
|
||||
| 'SlideOut'
|
||||
| 'Rotate'
|
||||
| 'Scale'
|
||||
| 'Pulse'
|
||||
| { Custom: string };
|
||||
|
||||
export type EasingFunction =
|
||||
| 'Linear'
|
||||
| 'EaseIn'
|
||||
| 'EaseOut'
|
||||
| 'EaseInOut'
|
||||
| 'Bounce'
|
||||
| 'Elastic';
|
||||
|
||||
export type BlendMode =
|
||||
| 'Normal'
|
||||
| 'Multiply'
|
||||
| 'Screen'
|
||||
| 'Overlay'
|
||||
| 'SoftLight'
|
||||
| 'HardLight'
|
||||
| 'ColorDodge'
|
||||
| 'ColorBurn';
|
||||
|
||||
export type QualityLevel = 'Low' | 'Medium' | 'High' | 'Lossless';
|
||||
|
||||
export interface WatermarkRemovalConfig {
|
||||
method: RemovalMethod;
|
||||
quality_level: QualityLevel;
|
||||
preserve_aspect_ratio: boolean;
|
||||
target_regions?: BoundingBox[];
|
||||
inpainting_model?: string;
|
||||
blur_radius?: number;
|
||||
crop_margin?: number;
|
||||
}
|
||||
|
||||
export type RemovalMethod =
|
||||
| 'Inpainting'
|
||||
| 'Blurring'
|
||||
| 'Cropping'
|
||||
| 'Masking'
|
||||
| 'ContentAware'
|
||||
| 'Clone';
|
||||
|
||||
export interface WatermarkDetectionConfig {
|
||||
similarity_threshold: number; // 0.0-1.0
|
||||
min_watermark_size: [number, number];
|
||||
max_watermark_size: [number, number];
|
||||
detection_regions: DetectionRegion[];
|
||||
frame_sample_rate: number;
|
||||
methods: DetectionMethod[];
|
||||
template_ids?: string[];
|
||||
}
|
||||
|
||||
export type DetectionRegion =
|
||||
| 'FullFrame'
|
||||
| 'Corners'
|
||||
| 'Edges'
|
||||
| 'Center'
|
||||
| { Custom: BoundingBox };
|
||||
|
||||
export interface BatchWatermarkTask {
|
||||
task_id: string;
|
||||
operation: WatermarkOperation;
|
||||
material_ids: string[];
|
||||
config: any; // 动态配置,根据operation类型解析
|
||||
status: BatchTaskStatus;
|
||||
progress: BatchProgress;
|
||||
created_at: string;
|
||||
started_at?: string;
|
||||
completed_at?: string;
|
||||
}
|
||||
|
||||
export type WatermarkOperation =
|
||||
| 'Detect'
|
||||
| 'Remove'
|
||||
| 'Add'
|
||||
| 'DetectAndRemove'
|
||||
| 'Replace';
|
||||
|
||||
export type BatchTaskStatus =
|
||||
| 'Pending'
|
||||
| 'Running'
|
||||
| 'Completed'
|
||||
| 'Failed'
|
||||
| 'Cancelled'
|
||||
| 'Paused';
|
||||
|
||||
export interface BatchProgress {
|
||||
total_items: number;
|
||||
processed_items: number;
|
||||
failed_items: number;
|
||||
current_item?: string;
|
||||
progress_percentage: number; // 0.0-100.0
|
||||
estimated_remaining_ms?: number;
|
||||
errors: string[];
|
||||
detection_results: WatermarkDetectionResult[];
|
||||
processing_results: WatermarkProcessingResult[];
|
||||
}
|
||||
|
||||
export interface WatermarkProcessingResult {
|
||||
material_id: string;
|
||||
operation: WatermarkOperation;
|
||||
success: boolean;
|
||||
output_path?: string;
|
||||
processing_time_ms: number;
|
||||
error_message?: string;
|
||||
metadata?: any;
|
||||
}
|
||||
|
||||
// 前端专用的扩展类型
|
||||
export interface WatermarkToolState {
|
||||
activeTab: 'detect' | 'remove' | 'add';
|
||||
isLoading: boolean;
|
||||
selectedMaterials: string[];
|
||||
selectedTemplate?: string;
|
||||
detectionConfig: WatermarkDetectionConfig;
|
||||
removalConfig: WatermarkRemovalConfig;
|
||||
additionConfig: WatermarkConfig;
|
||||
batchTask?: BatchWatermarkTask;
|
||||
}
|
||||
|
||||
export interface WatermarkTemplateUpload {
|
||||
name: string;
|
||||
file: File;
|
||||
category: WatermarkCategory;
|
||||
watermark_type: WatermarkType;
|
||||
description?: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface WatermarkDetectionResultDisplay extends WatermarkDetectionResult {
|
||||
material_name?: string;
|
||||
material_thumbnail?: string;
|
||||
detection_preview?: string; // 检测结果预览图
|
||||
}
|
||||
|
||||
// 水印处理事件类型
|
||||
export interface WatermarkProcessingEvent {
|
||||
type: 'task_started' | 'task_progress' | 'task_completed' | 'task_failed' | 'task_cancelled';
|
||||
task_id: string;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
// 水印模板管理相关
|
||||
export interface WatermarkTemplateFilter {
|
||||
category?: WatermarkCategory;
|
||||
watermark_type?: WatermarkType;
|
||||
search_text?: string;
|
||||
tags?: string[];
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface WatermarkTemplateStats {
|
||||
total_templates: number;
|
||||
by_category: Record<WatermarkCategory, number>;
|
||||
by_type: Record<WatermarkType, number>;
|
||||
total_size: number; // 总文件大小(字节)
|
||||
}
|
||||
|
||||
// 水印检测统计
|
||||
export interface WatermarkDetectionStats {
|
||||
total_detections: number;
|
||||
by_method: Record<DetectionMethod, number>;
|
||||
average_confidence: number;
|
||||
processing_time_stats: {
|
||||
min: number;
|
||||
max: number;
|
||||
average: number;
|
||||
};
|
||||
}
|
||||
|
||||
// 批量处理统计
|
||||
export interface BatchProcessingStats {
|
||||
total_tasks: number;
|
||||
by_operation: Record<WatermarkOperation, number>;
|
||||
by_status: Record<BatchTaskStatus, number>;
|
||||
success_rate: number;
|
||||
average_processing_time: number;
|
||||
}
|
||||
|
||||
// 水印处理历史记录
|
||||
export interface WatermarkProcessingHistory {
|
||||
id: string;
|
||||
material_id: string;
|
||||
operation: WatermarkOperation;
|
||||
config: any;
|
||||
result: WatermarkProcessingResult;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// 水印预设配置
|
||||
export interface WatermarkPreset {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
operation: WatermarkOperation;
|
||||
config: WatermarkConfig | WatermarkRemovalConfig | WatermarkDetectionConfig;
|
||||
is_default: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// 导出相关类型
|
||||
export interface WatermarkExportOptions {
|
||||
include_original: boolean;
|
||||
include_processed: boolean;
|
||||
export_format: 'zip' | 'folder';
|
||||
quality_level: QualityLevel;
|
||||
naming_pattern: string;
|
||||
}
|
||||
|
||||
export interface WatermarkExportResult {
|
||||
export_id: string;
|
||||
file_path: string;
|
||||
file_size: number;
|
||||
exported_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
423
docs/watermark-processing-tool.md
Normal file
423
docs/watermark-processing-tool.md
Normal file
@@ -0,0 +1,423 @@
|
||||
# 批量水印处理工具 - 技术文档
|
||||
|
||||
## 概述
|
||||
|
||||
批量水印处理工具是 MixVideo Desktop 应用的核心功能模块,提供智能水印检测、移除和批量添加功能。该工具集成到现有的视频处理流水线中,支持多种水印类型和处理算法。
|
||||
|
||||
## 功能特性
|
||||
|
||||
### 1. 水印检测
|
||||
- **多算法支持**: 模板匹配、边缘检测、频域分析、透明度检测
|
||||
- **智能采样**: 基于帧采样率的高效检测
|
||||
- **区域检测**: 支持全帧、四角、边缘、中心等检测区域
|
||||
- **置信度评分**: 提供检测结果的可信度评估
|
||||
|
||||
### 2. 水印移除
|
||||
- **多种方法**: AI修复、模糊处理、裁剪移除、遮罩覆盖、内容感知填充、克隆修复
|
||||
- **质量控制**: 支持低、中、高、无损四种质量级别
|
||||
- **区域指定**: 可指定特定区域进行水印移除
|
||||
- **批量处理**: 支持大规模视频文件的批量处理
|
||||
|
||||
### 3. 水印添加
|
||||
- **多种类型**: 图片水印、矢量水印、文字水印、动态水印
|
||||
- **灵活定位**: 9个预设位置 + 自定义位置 + 动态位置
|
||||
- **丰富效果**: 透明度、缩放、旋转、动画、混合模式
|
||||
- **智能避让**: 可避开人脸、文字等重要内容
|
||||
|
||||
### 4. 模板管理
|
||||
- **分类管理**: Logo、版权、签名、装饰、自定义分类
|
||||
- **格式支持**: PNG、JPG、SVG、GIF等多种格式
|
||||
- **缩略图生成**: 自动生成预览缩略图
|
||||
- **导入导出**: 支持模板的导入导出功能
|
||||
|
||||
## 技术架构
|
||||
|
||||
### 后端架构 (Rust)
|
||||
|
||||
```
|
||||
apps/desktop/src-tauri/src/
|
||||
├── data/
|
||||
│ ├── models/watermark.rs # 数据模型定义
|
||||
│ └── repositories/
|
||||
│ └── watermark_template_repository.rs # 数据访问层
|
||||
├── business/
|
||||
│ ├── services/
|
||||
│ │ ├── watermark_detection_service.rs # 检测服务
|
||||
│ │ ├── watermark_removal_service.rs # 移除服务
|
||||
│ │ ├── watermark_addition_service.rs # 添加服务
|
||||
│ │ ├── watermark_template_service.rs # 模板管理服务
|
||||
│ │ └── batch_watermark_processor.rs # 批量处理器
|
||||
│ └── errors/watermark_errors.rs # 错误处理
|
||||
└── presentation/
|
||||
└── commands/watermark_commands.rs # Tauri命令接口
|
||||
```
|
||||
|
||||
### 前端架构 (React + TypeScript)
|
||||
|
||||
```
|
||||
apps/desktop/src/
|
||||
├── components/
|
||||
│ └── WatermarkToolDialog.tsx # 主界面组件
|
||||
├── types/
|
||||
│ └── watermark.ts # 类型定义
|
||||
└── hooks/
|
||||
└── useWatermarkProcessing.ts # 自定义Hook
|
||||
```
|
||||
|
||||
### 数据库设计
|
||||
|
||||
```sql
|
||||
-- 水印模板表
|
||||
CREATE TABLE watermark_templates (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
thumbnail_path TEXT,
|
||||
category TEXT NOT NULL,
|
||||
watermark_type TEXT NOT NULL,
|
||||
file_size INTEGER NOT NULL,
|
||||
width INTEGER,
|
||||
height INTEGER,
|
||||
description TEXT,
|
||||
tags TEXT,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 水印检测结果表
|
||||
CREATE TABLE watermark_detection_results (
|
||||
id TEXT PRIMARY KEY,
|
||||
material_id TEXT NOT NULL,
|
||||
detection_method TEXT NOT NULL,
|
||||
detections TEXT NOT NULL,
|
||||
confidence_score REAL NOT NULL,
|
||||
processing_time_ms INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (material_id) REFERENCES materials(id)
|
||||
);
|
||||
|
||||
-- 批量处理任务表
|
||||
CREATE TABLE batch_watermark_tasks (
|
||||
task_id TEXT PRIMARY KEY,
|
||||
operation TEXT NOT NULL,
|
||||
material_ids TEXT NOT NULL,
|
||||
config TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'Pending',
|
||||
progress TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
started_at DATETIME,
|
||||
completed_at DATETIME
|
||||
);
|
||||
|
||||
-- 处理结果表
|
||||
CREATE TABLE watermark_processing_results (
|
||||
id TEXT PRIMARY KEY,
|
||||
material_id TEXT NOT NULL,
|
||||
operation TEXT NOT NULL,
|
||||
success INTEGER NOT NULL,
|
||||
output_path TEXT,
|
||||
processing_time_ms INTEGER NOT NULL,
|
||||
error_message TEXT,
|
||||
metadata TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (material_id) REFERENCES materials(id)
|
||||
);
|
||||
```
|
||||
|
||||
## API 接口
|
||||
|
||||
### Tauri 命令
|
||||
|
||||
#### 水印检测
|
||||
```rust
|
||||
#[tauri::command]
|
||||
pub async fn detect_watermarks_in_video(
|
||||
state: State<'_, AppState>,
|
||||
material_id: String,
|
||||
video_path: String,
|
||||
config: WatermarkDetectionConfig,
|
||||
) -> Result<WatermarkDetectionResult, String>
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn detect_watermarks_in_image(
|
||||
material_id: String,
|
||||
image_path: String,
|
||||
config: WatermarkDetectionConfig,
|
||||
) -> Result<WatermarkDetectionResult, String>
|
||||
```
|
||||
|
||||
#### 水印移除
|
||||
```rust
|
||||
#[tauri::command]
|
||||
pub async fn remove_watermarks_from_video(
|
||||
state: State<'_, AppState>,
|
||||
material_id: String,
|
||||
input_path: String,
|
||||
output_path: String,
|
||||
config: WatermarkRemovalConfig,
|
||||
) -> Result<WatermarkProcessingResult, String>
|
||||
```
|
||||
|
||||
#### 水印添加
|
||||
```rust
|
||||
#[tauri::command]
|
||||
pub async fn add_watermark_to_video(
|
||||
state: State<'_, AppState>,
|
||||
material_id: String,
|
||||
input_path: String,
|
||||
output_path: String,
|
||||
watermark_path: String,
|
||||
config: WatermarkConfig,
|
||||
) -> Result<WatermarkProcessingResult, String>
|
||||
```
|
||||
|
||||
#### 批量处理
|
||||
```rust
|
||||
#[tauri::command]
|
||||
pub async fn start_batch_watermark_task(
|
||||
state: State<'_, AppState>,
|
||||
operation: WatermarkOperation,
|
||||
material_ids: Vec<String>,
|
||||
config: serde_json::Value,
|
||||
) -> Result<String, String>
|
||||
```
|
||||
|
||||
#### 模板管理
|
||||
```rust
|
||||
#[tauri::command]
|
||||
pub async fn get_watermark_templates(
|
||||
category: Option<String>,
|
||||
watermark_type: Option<String>,
|
||||
) -> Result<Vec<WatermarkTemplate>, String>
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn upload_watermark_template(
|
||||
name: String,
|
||||
file_path: String,
|
||||
category: String,
|
||||
watermark_type: String,
|
||||
description: Option<String>,
|
||||
tags: Vec<String>,
|
||||
) -> Result<WatermarkTemplate, String>
|
||||
```
|
||||
|
||||
## 配置参数
|
||||
|
||||
### 检测配置 (WatermarkDetectionConfig)
|
||||
```typescript
|
||||
interface WatermarkDetectionConfig {
|
||||
similarity_threshold: number; // 相似度阈值 0.0-1.0
|
||||
min_watermark_size: [number, number]; // 最小水印尺寸
|
||||
max_watermark_size: [number, number]; // 最大水印尺寸
|
||||
detection_regions: DetectionRegion[]; // 检测区域
|
||||
frame_sample_rate: number; // 帧采样率
|
||||
methods: DetectionMethod[]; // 检测方法
|
||||
template_ids?: string[]; // 指定模板ID
|
||||
}
|
||||
```
|
||||
|
||||
### 移除配置 (WatermarkRemovalConfig)
|
||||
```typescript
|
||||
interface WatermarkRemovalConfig {
|
||||
method: RemovalMethod; // 移除方法
|
||||
quality_level: QualityLevel; // 质量级别
|
||||
preserve_aspect_ratio: boolean; // 保持宽高比
|
||||
target_regions?: BoundingBox[]; // 目标区域
|
||||
blur_radius?: number; // 模糊半径
|
||||
crop_margin?: number; // 裁剪边距
|
||||
}
|
||||
```
|
||||
|
||||
### 添加配置 (WatermarkConfig)
|
||||
```typescript
|
||||
interface WatermarkConfig {
|
||||
watermark_type: WatermarkType; // 水印类型
|
||||
position: WatermarkPosition; // 位置
|
||||
opacity: number; // 透明度 0.0-1.0
|
||||
scale: number; // 缩放比例
|
||||
rotation: number; // 旋转角度
|
||||
animation?: WatermarkAnimation; // 动画配置
|
||||
blend_mode: BlendMode; // 混合模式
|
||||
quality_level: QualityLevel; // 质量级别
|
||||
}
|
||||
```
|
||||
|
||||
## 性能优化
|
||||
|
||||
### 1. 并行处理
|
||||
- 使用 Rayon 并行处理多个视频文件
|
||||
- 异步任务队列管理批量处理
|
||||
- 智能任务调度和资源分配
|
||||
|
||||
### 2. 内存优化
|
||||
- 分块处理大视频文件
|
||||
- 及时释放临时资源
|
||||
- 缓存机制减少重复计算
|
||||
|
||||
### 3. 算法优化
|
||||
- 智能帧采样减少计算量
|
||||
- 多级检测策略提高效率
|
||||
- 预处理优化提升检测精度
|
||||
|
||||
### 4. 缓存策略
|
||||
- 检测结果缓存
|
||||
- 水印模板缓存
|
||||
- 缩略图缓存
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 错误类型
|
||||
```rust
|
||||
pub enum WatermarkError {
|
||||
DetectionFailed { message: String },
|
||||
RemovalFailed { message: String },
|
||||
AdditionFailed { message: String },
|
||||
UnsupportedFormat { format: String, supported: Vec<String> },
|
||||
FFmpegError { message: String },
|
||||
TemplateNotFound { template_id: String },
|
||||
BatchTaskFailed { task_id: String, message: String },
|
||||
// ... 更多错误类型
|
||||
}
|
||||
```
|
||||
|
||||
### 错误分类
|
||||
- **用户错误**: 输入验证、格式不支持、模板不存在等
|
||||
- **系统错误**: FFmpeg执行失败、文件操作失败、数据库错误等
|
||||
- **网络错误**: 连接超时、请求失败等(可重试)
|
||||
|
||||
### 错误恢复
|
||||
- 自动重试机制
|
||||
- 优雅降级处理
|
||||
- 详细错误日志记录
|
||||
|
||||
## 测试策略
|
||||
|
||||
### 单元测试
|
||||
- 数据模型验证
|
||||
- 业务逻辑测试
|
||||
- 错误处理测试
|
||||
- 配置验证测试
|
||||
|
||||
### 集成测试
|
||||
- 端到端处理流程
|
||||
- 数据库操作测试
|
||||
- 文件系统交互测试
|
||||
|
||||
### 性能测试
|
||||
- 大批量文件处理
|
||||
- 内存使用监控
|
||||
- 处理时间基准测试
|
||||
|
||||
## 部署和配置
|
||||
|
||||
### 依赖要求
|
||||
- FFmpeg (视频处理)
|
||||
- SQLite (数据存储)
|
||||
- OpenCV (图像处理,可选)
|
||||
|
||||
### 配置文件
|
||||
```toml
|
||||
[watermark]
|
||||
# 默认检测配置
|
||||
default_similarity_threshold = 0.8
|
||||
default_frame_sample_rate = 30
|
||||
|
||||
# 性能配置
|
||||
max_concurrent_tasks = 4
|
||||
max_memory_usage_mb = 2048
|
||||
|
||||
# 文件路径配置
|
||||
template_directory = "watermarks/templates"
|
||||
cache_directory = "watermarks/cache"
|
||||
temp_directory = "watermarks/temp"
|
||||
```
|
||||
|
||||
### 目录结构
|
||||
```
|
||||
watermarks/
|
||||
├── templates/ # 水印模板存储
|
||||
│ ├── {template_id}/
|
||||
│ │ ├── template.png
|
||||
│ │ └── thumbnail.jpg
|
||||
├── cache/ # 缓存文件
|
||||
└── temp/ # 临时文件
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 前端调用示例
|
||||
```typescript
|
||||
// 检测水印
|
||||
const detectionResult = await invoke('detect_watermarks_in_video', {
|
||||
materialId: 'material_123',
|
||||
videoPath: '/path/to/video.mp4',
|
||||
config: {
|
||||
similarity_threshold: 0.8,
|
||||
frame_sample_rate: 30,
|
||||
methods: ['TemplateMatching', 'EdgeDetection']
|
||||
}
|
||||
});
|
||||
|
||||
// 批量移除水印
|
||||
const taskId = await invoke('start_batch_watermark_task', {
|
||||
operation: 'Remove',
|
||||
materialIds: ['material_1', 'material_2'],
|
||||
config: {
|
||||
method: 'Blurring',
|
||||
quality_level: 'Medium',
|
||||
blur_radius: 5.0
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## 扩展性
|
||||
|
||||
### 新增检测算法
|
||||
1. 在 `DetectionMethod` 枚举中添加新方法
|
||||
2. 在 `WatermarkDetectionService` 中实现算法
|
||||
3. 更新配置和文档
|
||||
|
||||
### 新增移除方法
|
||||
1. 在 `RemovalMethod` 枚举中添加新方法
|
||||
2. 在 `WatermarkRemovalService` 中实现方法
|
||||
3. 添加相应的配置参数
|
||||
|
||||
### 新增水印类型
|
||||
1. 在 `WatermarkType` 枚举中添加类型
|
||||
2. 更新模板验证逻辑
|
||||
3. 实现对应的处理逻辑
|
||||
|
||||
## 维护和监控
|
||||
|
||||
### 日志记录
|
||||
- 结构化日志输出
|
||||
- 性能指标记录
|
||||
- 错误详情追踪
|
||||
|
||||
### 监控指标
|
||||
- 处理成功率
|
||||
- 平均处理时间
|
||||
- 内存使用情况
|
||||
- 错误频率统计
|
||||
|
||||
### 故障排查
|
||||
- 详细的错误上下文
|
||||
- 处理步骤追踪
|
||||
- 性能瓶颈分析
|
||||
|
||||
## 版本历史
|
||||
|
||||
### v1.0.0 (当前版本)
|
||||
- 基础水印检测、移除、添加功能
|
||||
- 模板管理系统
|
||||
- 批量处理支持
|
||||
- 错误处理和日志系统
|
||||
- 单元测试和集成测试
|
||||
|
||||
### 未来规划
|
||||
- AI增强的水印检测算法
|
||||
- 更多水印类型支持
|
||||
- 云端处理能力
|
||||
- 实时预览功能
|
||||
- 性能优化和算法改进
|
||||
284
docs/watermark-tool-user-guide.md
Normal file
284
docs/watermark-tool-user-guide.md
Normal file
@@ -0,0 +1,284 @@
|
||||
# 批量水印处理工具 - 用户使用指南
|
||||
|
||||
## 功能概述
|
||||
|
||||
批量水印处理工具是 MixVideo Desktop 的核心功能之一,帮助您快速处理视频和图片中的水印。支持三大核心功能:
|
||||
|
||||
- **🔍 水印检测**: 智能识别视频/图片中的水印位置和类型
|
||||
- **🗑️ 水印移除**: 使用多种算法移除不需要的水印
|
||||
- **➕ 水印添加**: 为内容添加自定义水印保护版权
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 打开水印工具
|
||||
|
||||
1. 在项目详情页面选择需要处理的素材
|
||||
2. 点击工具栏中的"水印处理"按钮
|
||||
3. 水印处理对话框将会打开
|
||||
|
||||
### 2. 选择处理模式
|
||||
|
||||
工具提供三个标签页,对应不同的处理模式:
|
||||
|
||||
- **检测水印**: 分析素材中的水印
|
||||
- **移除水印**: 去除现有水印
|
||||
- **添加水印**: 添加新的水印
|
||||
|
||||
## 详细功能说明
|
||||
|
||||
### 🔍 水印检测
|
||||
|
||||
#### 功能说明
|
||||
水印检测功能可以自动识别视频或图片中的水印,并提供详细的检测报告。
|
||||
|
||||
#### 配置选项
|
||||
|
||||
**相似度阈值** (0.1 - 1.0)
|
||||
- 控制检测的敏感度
|
||||
- 数值越高,检测越严格
|
||||
- 建议值:0.8
|
||||
|
||||
**帧采样率** (1 - 60)
|
||||
- 对于视频,每隔多少帧检测一次
|
||||
- 数值越小,检测越精确,但处理时间更长
|
||||
- 建议值:30
|
||||
|
||||
**检测方法**
|
||||
- ✅ **模板匹配**: 基于已知水印模板进行匹配
|
||||
- ✅ **边缘检测**: 检测重复出现的边缘特征
|
||||
- ✅ **频域分析**: 分析图像频域中的周期性模式
|
||||
- ✅ **透明度检测**: 识别半透明叠加层
|
||||
|
||||
#### 使用步骤
|
||||
|
||||
1. 选择"检测水印"标签页
|
||||
2. 调整检测参数(使用默认值即可)
|
||||
3. 点击"开始检测水印"按钮
|
||||
4. 等待检测完成,查看结果报告
|
||||
|
||||
#### 检测结果
|
||||
|
||||
检测完成后,您将看到:
|
||||
- 检测到的水印数量
|
||||
- 每个水印的位置和置信度
|
||||
- 水印类型(如果能识别)
|
||||
- 处理时间统计
|
||||
|
||||
### 🗑️ 水印移除
|
||||
|
||||
#### 功能说明
|
||||
水印移除功能提供多种算法来去除不需要的水印,保持视频/图片的整体质量。
|
||||
|
||||
#### 移除方法
|
||||
|
||||
**AI修复** (推荐)
|
||||
- 使用人工智能算法智能填充水印区域
|
||||
- 效果最佳,但处理时间较长
|
||||
- 适用于复杂背景的水印
|
||||
|
||||
**模糊处理**
|
||||
- 对水印区域进行模糊处理
|
||||
- 处理速度快,适用于简单场景
|
||||
- 可调节模糊半径 (1-20)
|
||||
|
||||
**裁剪移除**
|
||||
- 通过裁剪去除边缘水印
|
||||
- 会改变视频/图片尺寸
|
||||
- 可设置裁剪边距 (像素)
|
||||
|
||||
**遮罩覆盖**
|
||||
- 使用纯色或图案覆盖水印
|
||||
- 适用于固定位置的水印
|
||||
- 处理速度最快
|
||||
|
||||
**内容感知填充**
|
||||
- 分析周围内容智能填充
|
||||
- 效果自然,适用于纹理背景
|
||||
|
||||
**克隆修复**
|
||||
- 使用周围相似区域修复水印
|
||||
- 适用于重复纹理背景
|
||||
|
||||
#### 质量级别
|
||||
|
||||
- **低质量(快速)**: 优先处理速度
|
||||
- **中等质量**: 平衡质量和速度
|
||||
- **高质量**: 优先输出质量
|
||||
- **无损质量**: 最高质量,处理时间最长
|
||||
|
||||
#### 使用步骤
|
||||
|
||||
1. 选择"移除水印"标签页
|
||||
2. 选择移除方法和质量级别
|
||||
3. 如果选择模糊处理,调整模糊半径
|
||||
4. 点击"开始移除水印"按钮
|
||||
5. 等待处理完成
|
||||
|
||||
### ➕ 水印添加
|
||||
|
||||
#### 功能说明
|
||||
为您的内容添加自定义水印,保护版权或添加品牌标识。
|
||||
|
||||
#### 水印模板管理
|
||||
|
||||
**上传新模板**
|
||||
1. 点击"上传新模板"按钮
|
||||
2. 选择水印文件(支持 PNG、JPG、SVG、GIF)
|
||||
3. 填写模板信息:
|
||||
- 模板名称
|
||||
- 分类(Logo、版权、签名、装饰、自定义)
|
||||
- 描述和标签
|
||||
4. 确认上传
|
||||
|
||||
**模板分类**
|
||||
- **Logo**: 公司或品牌标识
|
||||
- **版权**: 版权声明文字或图标
|
||||
- **签名**: 个人签名或标识
|
||||
- **装饰**: 装饰性图案
|
||||
- **自定义**: 其他类型水印
|
||||
|
||||
#### 水印配置
|
||||
|
||||
**位置设置**
|
||||
- 9个预设位置:左上、顶部居中、右上、左侧居中、居中、右侧居中、左下、底部居中、右下
|
||||
- 自定义位置:手动指定坐标
|
||||
- 动态位置:智能避开人脸和文字
|
||||
|
||||
**外观设置**
|
||||
- **透明度** (10% - 100%): 控制水印的透明程度
|
||||
- **缩放比例** (0.1x - 3.0x): 调整水印大小
|
||||
- **旋转角度** (-180° - 180°): 旋转水印角度
|
||||
|
||||
**高级设置**
|
||||
- **混合模式**: 正常、叠加、柔光等
|
||||
- **动画效果**: 淡入、滑入、旋转等(仅视频)
|
||||
- **质量级别**: 控制输出质量
|
||||
|
||||
#### 使用步骤
|
||||
|
||||
1. 选择"添加水印"标签页
|
||||
2. 从模板库中选择水印模板
|
||||
3. 调整位置、透明度、缩放等参数
|
||||
4. 预览效果(如果支持)
|
||||
5. 点击"开始添加水印"按钮
|
||||
6. 等待处理完成
|
||||
|
||||
## 批量处理
|
||||
|
||||
### 处理进度
|
||||
|
||||
当启动批量处理任务后,您可以看到:
|
||||
|
||||
- **总体进度**: 显示处理百分比
|
||||
- **处理统计**: 已处理/总数量/失败数量
|
||||
- **当前项目**: 正在处理的素材名称
|
||||
- **预估时间**: 剩余处理时间
|
||||
- **错误信息**: 如果有处理失败的项目
|
||||
|
||||
### 任务管理
|
||||
|
||||
- **暂停/继续**: 可以暂停正在进行的任务
|
||||
- **取消任务**: 停止处理并清理临时文件
|
||||
- **查看详情**: 查看每个素材的处理结果
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 检测水印
|
||||
|
||||
1. **首次使用建议使用默认参数**,根据结果调整
|
||||
2. **对于低质量视频**,适当降低相似度阈值
|
||||
3. **对于大文件**,可以增加帧采样率以提高速度
|
||||
4. **组合使用多种检测方法**以提高准确率
|
||||
|
||||
### 移除水印
|
||||
|
||||
1. **优先尝试AI修复**,效果通常最好
|
||||
2. **对于边缘水印**,考虑使用裁剪移除
|
||||
3. **对于简单背景**,模糊处理效果不错且速度快
|
||||
4. **处理重要内容时选择高质量级别**
|
||||
|
||||
### 添加水印
|
||||
|
||||
1. **选择合适的位置**,避免遮挡重要内容
|
||||
2. **调整透明度**,既要保护版权又不影响观看
|
||||
3. **对于不同类型的内容使用不同的水印**
|
||||
4. **定期备份水印模板**
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 检测不到水印怎么办?
|
||||
A: 尝试以下方法:
|
||||
- 降低相似度阈值
|
||||
- 增加检测方法
|
||||
- 检查水印是否在检测区域内
|
||||
- 对于视频,减少帧采样率
|
||||
|
||||
### Q: 移除水印后效果不理想?
|
||||
A: 可以尝试:
|
||||
- 更换移除方法
|
||||
- 提高质量级别
|
||||
- 手动指定水印区域
|
||||
- 使用AI修复方法
|
||||
|
||||
### Q: 添加的水印位置不合适?
|
||||
A: 建议:
|
||||
- 使用动态位置避开重要内容
|
||||
- 手动调整位置坐标
|
||||
- 降低透明度减少干扰
|
||||
- 选择合适的混合模式
|
||||
|
||||
### Q: 处理速度太慢?
|
||||
A: 优化建议:
|
||||
- 选择较低的质量级别
|
||||
- 增加帧采样率(检测时)
|
||||
- 减少同时处理的文件数量
|
||||
- 关闭不必要的检测方法
|
||||
|
||||
### Q: 批量处理中断了怎么办?
|
||||
A: 处理方法:
|
||||
- 重新启动任务,已处理的文件会被跳过
|
||||
- 检查错误日志找出问题原因
|
||||
- 单独处理失败的文件
|
||||
- 确保有足够的磁盘空间
|
||||
|
||||
## 技术限制
|
||||
|
||||
### 支持的文件格式
|
||||
|
||||
**视频格式**
|
||||
- MP4, AVI, MOV, MKV, WMV, FLV
|
||||
|
||||
**图片格式**
|
||||
- JPG, PNG, BMP, TIFF, WebP
|
||||
|
||||
**水印格式**
|
||||
- PNG (推荐,支持透明)
|
||||
- JPG, BMP, TIFF
|
||||
- SVG (矢量图)
|
||||
- GIF (动态水印)
|
||||
|
||||
### 性能要求
|
||||
|
||||
- **内存**: 建议 8GB 以上
|
||||
- **存储**: 处理过程中需要额外存储空间
|
||||
- **CPU**: 多核处理器可提高批量处理速度
|
||||
|
||||
### 注意事项
|
||||
|
||||
1. **备份原文件**: 处理前请备份重要文件
|
||||
2. **版权合规**: 确保有权处理相关内容
|
||||
3. **质量损失**: 某些处理可能导致轻微质量损失
|
||||
4. **处理时间**: 高质量处理需要更多时间
|
||||
|
||||
## 获取帮助
|
||||
|
||||
如果您在使用过程中遇到问题:
|
||||
|
||||
1. 查看本用户指南的常见问题部分
|
||||
2. 检查应用程序的日志文件
|
||||
3. 联系技术支持团队
|
||||
4. 访问在线帮助文档
|
||||
|
||||
---
|
||||
|
||||
*本指南会随着功能更新而持续完善,建议定期查看最新版本。*
|
||||
Reference in New Issue
Block a user