From 3dbdca4ee61322a71252cec5d1b7fc5d650103d1 Mon Sep 17 00:00:00 2001
From: imeepos
Date: Fri, 18 Jul 2025 11:11:10 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0AI=E7=94=9F=E6=88=90?=
=?UTF-8?q?=E8=A7=86=E9=A2=91=E5=89=8D=E7=BD=AE=E8=B7=B3=E8=BF=87=E5=8A=9F?=
=?UTF-8?q?=E8=83=BD=20v0.1.35?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 添加skip_start_ms参数到CreateMaterialRequest和MaterialProcessingConfig
- 在MaterialImportDialog中添加前置跳过毫秒数输入框
- 实现FFmpegService::create_trimmed_video方法创建跳过开头的临时视频
- 在场景检测前处理视频前置跳过,避免AI生成视频相同首帧问题
- 支持同步和异步处理模式,自动调整场景时间戳补偿跳过时间
- 自动清理临时文件,确保资源管理正确
解决问题:
- AI生成视频第一帧相同导致切片后视频首帧重复
- 通过跳过前置毫秒数避免相同首帧进入最终切片结果
---
.../services/async_material_service.rs | 56 ++++++++++++++++++-
.../src/business/services/material_service.rs | 54 +++++++++++++++++-
.../src-tauri/src/data/models/material.rs | 3 +
.../src-tauri/src/infrastructure/ffmpeg.rs | 55 ++++++++++++++++++
.../commands/material_commands.rs | 3 +
.../src/components/MaterialImportDialog.tsx | 24 +++++++-
apps/desktop/src/types/material.ts | 1 +
7 files changed, 191 insertions(+), 5 deletions(-)
diff --git a/apps/desktop/src-tauri/src/business/services/async_material_service.rs b/apps/desktop/src-tauri/src/business/services/async_material_service.rs
index f761493..95ebefa 100644
--- a/apps/desktop/src-tauri/src/business/services/async_material_service.rs
+++ b/apps/desktop/src-tauri/src/business/services/async_material_service.rs
@@ -306,9 +306,61 @@ impl AsyncMaterialService {
let original_path = material.original_path.clone();
let threshold = config.scene_detection_threshold;
-
+ let skip_start_ms = config.skip_start_ms;
+
match task::spawn_blocking(move || {
- MaterialService::detect_video_scenes(&original_path, threshold)
+ // 如果设置了跳过开头,先创建临时视频文件
+ let detection_file_path = if let Some(skip_ms) = skip_start_ms {
+ if skip_ms > 0 {
+ println!("异步处理 - AI生成视频前置跳过: {}ms", skip_ms);
+ match crate::infrastructure::ffmpeg::FFmpegService::create_trimmed_video(&original_path, skip_ms) {
+ Ok(temp_path) => {
+ println!("异步处理 - 临时视频创建成功: {}", temp_path);
+ temp_path
+ }
+ Err(e) => {
+ eprintln!("异步处理 - 创建临时视频失败,使用原视频: {}", e);
+ original_path.clone()
+ }
+ }
+ } else {
+ original_path.clone()
+ }
+ } else {
+ original_path.clone()
+ };
+
+ let result = MaterialService::detect_video_scenes(&detection_file_path, threshold);
+
+ // 处理结果并调整时间戳
+ let final_result = match result {
+ Ok(mut scene_detection) => {
+ // 如果使用了临时文件,需要调整场景时间戳
+ if let Some(skip_ms) = skip_start_ms {
+ if skip_ms > 0 && detection_file_path != original_path {
+ let skip_seconds = skip_ms as f64 / 1000.0;
+ println!("异步处理 - 调整场景时间戳,补偿跳过的{}秒", skip_seconds);
+ for scene in &mut scene_detection.scenes {
+ scene.start_time += skip_seconds;
+ scene.end_time += skip_seconds;
+ }
+ }
+ }
+ Ok(scene_detection)
+ }
+ Err(e) => Err(e),
+ };
+
+ // 清理临时文件
+ if detection_file_path != original_path {
+ if let Err(e) = std::fs::remove_file(&detection_file_path) {
+ eprintln!("异步处理 - 清理临时文件失败: {}", e);
+ } else {
+ println!("异步处理 - 临时文件清理成功: {}", detection_file_path);
+ }
+ }
+
+ final_result
}).await? {
Ok(scene_detection) => {
info!("异步场景检测成功,发现 {} 个场景", scene_detection.scenes.len());
diff --git a/apps/desktop/src-tauri/src/business/services/material_service.rs b/apps/desktop/src-tauri/src/business/services/material_service.rs
index 2f19584..8286339 100644
--- a/apps/desktop/src-tauri/src/business/services/material_service.rs
+++ b/apps/desktop/src-tauri/src/business/services/material_service.rs
@@ -344,15 +344,65 @@ impl MaterialService {
// 2. 场景检测(如果是视频且启用了场景检测)
if matches!(material.material_type, MaterialType::Video) && config.enable_scene_detection {
println!("开始视频场景检测: {}", material.original_path);
- match Self::detect_video_scenes(&material.original_path, config.scene_detection_threshold) {
- Ok(scene_detection) => {
+
+ // 如果设置了跳过开头,先创建临时视频文件
+ let detection_file_path = if let Some(skip_ms) = config.skip_start_ms {
+ if skip_ms > 0 {
+ println!("AI生成视频前置跳过: {}ms", skip_ms);
+ match crate::infrastructure::ffmpeg::FFmpegService::create_trimmed_video(&material.original_path, skip_ms) {
+ Ok(temp_path) => {
+ println!("临时视频创建成功,用于场景检测: {}", temp_path);
+ temp_path
+ }
+ Err(e) => {
+ eprintln!("创建临时视频失败,使用原视频: {}", e);
+ material.original_path.clone()
+ }
+ }
+ } else {
+ material.original_path.clone()
+ }
+ } else {
+ material.original_path.clone()
+ };
+
+ match Self::detect_video_scenes(&detection_file_path, config.scene_detection_threshold) {
+ Ok(mut scene_detection) => {
+ // 如果使用了临时文件,需要调整场景时间戳
+ if let Some(skip_ms) = config.skip_start_ms {
+ if skip_ms > 0 && detection_file_path != material.original_path {
+ let skip_seconds = skip_ms as f64 / 1000.0;
+ println!("调整场景时间戳,补偿跳过的{}秒", skip_seconds);
+ for scene in &mut scene_detection.scenes {
+ scene.start_time += skip_seconds;
+ scene.end_time += skip_seconds;
+ }
+ }
+ }
+
println!("场景检测成功,发现 {} 个场景", scene_detection.scenes.len());
material.set_scene_detection(scene_detection);
repository.update(&material)?;
+
+ // 清理临时文件
+ if detection_file_path != material.original_path {
+ if let Err(e) = std::fs::remove_file(&detection_file_path) {
+ eprintln!("清理临时文件失败: {}", e);
+ } else {
+ println!("临时文件清理成功: {}", detection_file_path);
+ }
+ }
}
Err(e) => {
// 场景检测失败不应该导致整个处理失败
eprintln!("场景检测失败: {}", e);
+
+ // 清理临时文件
+ if detection_file_path != material.original_path {
+ if let Err(e) = std::fs::remove_file(&detection_file_path) {
+ eprintln!("清理临时文件失败: {}", e);
+ }
+ }
}
}
} else {
diff --git a/apps/desktop/src-tauri/src/data/models/material.rs b/apps/desktop/src-tauri/src/data/models/material.rs
index 5ae0eff..f019ab0 100644
--- a/apps/desktop/src-tauri/src/data/models/material.rs
+++ b/apps/desktop/src-tauri/src/data/models/material.rs
@@ -147,6 +147,7 @@ pub struct CreateMaterialRequest {
pub auto_process: bool,
pub max_segment_duration: Option, // 最大片段时长(秒)
pub model_id: Option, // 可选的模特绑定ID
+ pub skip_start_ms: Option, // 跳过视频开头的毫秒数(用于AI生成视频避免相同首帧)
}
/// 视频切分模式
@@ -174,6 +175,7 @@ pub struct MaterialProcessingConfig {
pub output_format: String, // 输出格式
pub auto_process: Option, // 是否自动处理
pub split_mode: VideoSplitMode, // 视频切分模式
+ pub skip_start_ms: Option, // 跳过视频开头的毫秒数(用于AI生成视频避免相同首帧)
}
impl Default for MaterialProcessingConfig {
@@ -187,6 +189,7 @@ impl Default for MaterialProcessingConfig {
output_format: "mp4".to_string(),
auto_process: Some(true),
split_mode: VideoSplitMode::Accurate, // 默认使用精确模式
+ skip_start_ms: None, // 默认不跳过开头
}
}
}
diff --git a/apps/desktop/src-tauri/src/infrastructure/ffmpeg.rs b/apps/desktop/src-tauri/src/infrastructure/ffmpeg.rs
index 564f065..1e8400e 100644
--- a/apps/desktop/src-tauri/src/infrastructure/ffmpeg.rs
+++ b/apps/desktop/src-tauri/src/infrastructure/ffmpeg.rs
@@ -245,6 +245,61 @@ impl FFmpegService {
None
}
+ /// 创建跳过开头指定毫秒数的临时视频文件
+ /// 返回临时文件路径,调用者负责清理
+ pub fn create_trimmed_video(input_path: &str, skip_start_ms: u32) -> Result {
+ if !Path::new(input_path).exists() {
+ return Err(anyhow!("输入文件不存在: {}", input_path));
+ }
+
+ if skip_start_ms == 0 {
+ // 如果不需要跳过,直接返回原文件路径
+ return Ok(input_path.to_string());
+ }
+
+ // 创建临时文件路径
+ let input_path_obj = Path::new(input_path);
+ let file_stem = input_path_obj.file_stem()
+ .and_then(|s| s.to_str())
+ .unwrap_or("temp");
+ let extension = input_path_obj.extension()
+ .and_then(|s| s.to_str())
+ .unwrap_or("mp4");
+
+ let temp_dir = std::env::temp_dir();
+ let temp_file = temp_dir.join(format!("{}_trimmed_{}.{}", file_stem, skip_start_ms, extension));
+ let temp_path = temp_file.to_string_lossy().to_string();
+
+ println!("创建跳过前{}ms的临时视频: {} -> {}", skip_start_ms, input_path, temp_path);
+
+ // 使用FFmpeg跳过开头指定毫秒数
+ let skip_seconds = skip_start_ms as f64 / 1000.0;
+ let output = Self::create_hidden_command("ffmpeg")
+ .args([
+ "-i", input_path,
+ "-ss", &skip_seconds.to_string(), // 跳过开头
+ "-c", "copy", // 流复制,避免重新编码
+ "-avoid_negative_ts", "make_zero", // 避免负时间戳
+ "-y", // 覆盖输出文件
+ &temp_path
+ ])
+ .output()
+ .map_err(|e| anyhow!("执行视频跳过失败: {}", e))?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ return Err(anyhow!("FFmpeg视频跳过失败: {}", stderr));
+ }
+
+ // 验证输出文件是否存在
+ if !Path::new(&temp_path).exists() {
+ return Err(anyhow!("临时视频文件创建失败: {}", temp_path));
+ }
+
+ println!("临时视频创建成功: {}", temp_path);
+ Ok(temp_path)
+ }
+
/// 检测视频场景变化
pub fn detect_scenes(file_path: &str, threshold: f64) -> Result> {
if !Path::new(file_path).exists() {
diff --git a/apps/desktop/src-tauri/src/presentation/commands/material_commands.rs b/apps/desktop/src-tauri/src/presentation/commands/material_commands.rs
index 225b633..860c2e0 100644
--- a/apps/desktop/src-tauri/src/presentation/commands/material_commands.rs
+++ b/apps/desktop/src-tauri/src/presentation/commands/material_commands.rs
@@ -28,6 +28,9 @@ pub async fn import_materials(
if let Some(max_duration) = request.max_segment_duration {
config.max_segment_duration = max_duration;
}
+ if let Some(skip_ms) = request.skip_start_ms {
+ config.skip_start_ms = Some(skip_ms);
+ }
MaterialService::import_materials(repository, request, &config)
.map_err(|e| e.to_string())
diff --git a/apps/desktop/src/components/MaterialImportDialog.tsx b/apps/desktop/src/components/MaterialImportDialog.tsx
index 2f812f9..ca665be 100644
--- a/apps/desktop/src/components/MaterialImportDialog.tsx
+++ b/apps/desktop/src/components/MaterialImportDialog.tsx
@@ -41,7 +41,8 @@ export const MaterialImportDialog: React.FC = ({
const [selectedFiles, setSelectedFiles] = useState([]);
const [autoProcess, setAutoProcess] = useState(true);
- const [maxSegmentDuration, setMaxSegmentDuration] = useState(300); // 5分钟
+ const [maxSegmentDuration, setMaxSegmentDuration] = useState(3); // 5分钟
+ const [skipStartMs, setSkipStartMs] = useState(200); // 跳过开头毫秒数
const [ffmpegAvailable, setFFmpegAvailable] = useState(false);
const [step, setStep] = useState<'select' | 'batch' | 'configure' | 'importing' | 'complete'>('select');
const [importMode, setImportMode] = useState<'files' | 'folders'>('files');
@@ -208,6 +209,7 @@ export const MaterialImportDialog: React.FC = ({
auto_process: autoProcess,
max_segment_duration: maxSegmentDuration,
model_id: selectedModelId || undefined,
+ skip_start_ms: skipStartMs > 0 ? skipStartMs : undefined,
};
console.log('前端准备导入,选择的模特ID:', selectedModelId);
@@ -444,6 +446,26 @@ export const MaterialImportDialog: React.FC = ({
选择模特后,导入的素材将自动绑定到该模特
+
+ {/* AI生成视频前置跳过设置 */}
+
+
+
setSkipStartMs(Number(e.target.value))}
+ min="0"
+ max="10000"
+ step="100"
+ className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-blue-500 focus:border-blue-500"
+ />
+
+ AI生成的视频通常前几帧相同,设置跳过毫秒数可避免切片后首帧重复问题
+
+
)}
diff --git a/apps/desktop/src/types/material.ts b/apps/desktop/src/types/material.ts
index 054bf27..9332311 100644
--- a/apps/desktop/src/types/material.ts
+++ b/apps/desktop/src/types/material.ts
@@ -102,6 +102,7 @@ export interface CreateMaterialRequest {
auto_process: boolean;
max_segment_duration?: number;
model_id?: string; // 可选的模特绑定ID
+ skip_start_ms?: number; // 跳过视频开头的毫秒数(用于AI生成视频避免相同首帧)
}
export interface MaterialProcessingConfig {