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 {