diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 3f0fcf80..00ca9bf8 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -16,7 +16,10 @@ use crate::{ ColmapAccelerationStatus, EnginePaths, EngineStatus, }, error::{Result, SplatError}, - pipeline::runner::{PipelineResult, PipelineRunner}, + pipeline::{ + estimate::{estimate_runtime, RuntimeEstimate}, + runner::{PipelineResult, PipelineRunner}, + }, presets::Quality, project::{ catalog::{self, AppSettings, ProjectOverview}, @@ -107,6 +110,7 @@ pub struct GaussianVideoExportResult { pub struct ProbeAndPlan { video: VideoInfo, plan: FramePlan, + estimate: RuntimeEstimate, } fn paths_for_app(app: &tauri::AppHandle) -> EnginePaths { @@ -129,9 +133,21 @@ pub async fn probe_and_plan( path: String, quality: Quality, ) -> std::result::Result { - let video = probe_video(&paths_for_app(&app).ffprobe, &PathBuf::from(path), None).await?; + let engine_paths = paths_for_app(&app); + let video = probe_video(&engine_paths.ffprobe, &PathBuf::from(path), None).await?; let plan = UniformRatioFrameSelection.create_plan(&video, &quality.preset()); - Ok(ProbeAndPlan { video, plan }) + let acceleration = detect_colmap_acceleration(&engine_paths).await; + let total_vram_mb = acceleration + .device + .as_ref() + .and_then(|device| device.total_memory_mb); + let samples = catalog::runtime_samples().await; + let estimate = estimate_runtime(&video, &plan, quality, total_vram_mb, &samples); + Ok(ProbeAndPlan { + video, + plan, + estimate, + }) } #[tauri::command] @@ -224,6 +240,57 @@ pub async fn start_pipeline( result } +#[tauri::command] +pub async fn resume_pipeline( + app: tauri::AppHandle, + state: State<'_, PipelineController>, + telemetry: State<'_, TelemetryService>, + project_id: String, +) -> std::result::Result { + let project_id = parse_project_id(&project_id)?; + let (_, metadata) = catalog::load_registered_project(project_id).await?; + let emitter = app.clone(); + let started = Instant::now(); + let telemetry_session = Arc::new(PipelineTelemetrySession::new( + telemetry.inner().clone(), + metadata.quality, + )); + let event_telemetry = telemetry_session.clone(); + let runner = Arc::new(PipelineRunner::new(paths_for_app(&app), move |event| { + event_telemetry.observe(&event); + let _ = emitter.emit("pipeline-event", event); + })); + { + let mut active = state.active.lock().await; + if active.is_some() { + return Err(SplatError::Process("已有任务正在运行".into())); + } + *active = Some(runner.clone()); + } + telemetry_session.generation_started(); + let result = runner.resume(project_id).await; + match &result { + Ok(output) => telemetry_session.generation_completed( + output.duration_ms, + output.input_images, + output.source_duration_seconds, + ), + Err(error) => telemetry_session.generation_failed(error), + } + if let Err(error) = &result { + let stage = if matches!(error, SplatError::Cancelled) { + crate::pipeline::PipelineStage::Cancelled + } else { + crate::pipeline::PipelineStage::Failed + }; + let mut event = crate::pipeline::PipelineEvent::mapped(stage, 1.0, error.to_string()); + event.elapsed_ms = started.elapsed().as_millis() as u64; + let _ = app.emit("pipeline-event", event); + } + *state.active.lock().await = None; + result +} + #[tauri::command] pub async fn cancel_pipeline(state: State<'_, PipelineController>) -> Result<()> { if let Some(runner) = state.active.lock().await.as_ref() { diff --git a/src-tauri/src/engines/brush.rs b/src-tauri/src/engines/brush.rs index dab67e8f..ac60a3a3 100644 --- a/src-tauri/src/engines/brush.rs +++ b/src-tauri/src/engines/brush.rs @@ -39,6 +39,10 @@ pub async fn train( preset.brush_iterations.to_string().into(), OsString::from("--max-resolution"), preset.brush_max_resolution.to_string().into(), + OsString::from("--max-splats"), + preset.brush_max_splats.to_string().into(), + OsString::from("--sh-degree"), + preset.brush_sh_degree.to_string().into(), OsString::from("--export-every"), preset.brush_iterations.to_string().into(), OsString::from("--export-path"), @@ -53,9 +57,10 @@ pub async fn train( }) .await?; if !output.success { + let detail = process_error_detail(&output.stdout, &output.stderr); return Err(SplatError::Process(format!( - "Brush 退出码 {:?}", - output.exit_code + "Brush 退出码 {:?}:{detail}", + output.exit_code, ))); } let candidate = if candidate.is_file() { @@ -76,3 +81,45 @@ pub async fn train( } Ok(candidate) } + +fn process_error_detail(stdout: &str, stderr: &str) -> String { + stderr + .lines() + .chain(stdout.lines()) + .rev() + .find(|line| !line.trim().is_empty()) + .map(str::trim) + .unwrap_or("未提供错误详情") + .chars() + .take(500) + .collect() +} + +pub fn is_out_of_memory(error: &SplatError) -> bool { + let normalized = error.to_string().to_ascii_lowercase(); + [ + "out of memory", + "outofmemory", + "buffer too big", + "buffertoobig", + "allocation failed", + "device lost", + ] + .iter() + .any(|needle| normalized.contains(needle)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn recognizes_common_webgpu_oom_messages() { + assert!(is_out_of_memory(&SplatError::Process( + "Brush: BufferTooBig while allocating".into() + ))); + assert!(!is_out_of_memory(&SplatError::Process( + "invalid dataset".into() + ))); + } +} diff --git a/src-tauri/src/engines/colmap.rs b/src-tauri/src/engines/colmap.rs index 0800fe6f..ae06b7f5 100644 --- a/src-tauri/src/engines/colmap.rs +++ b/src-tauri/src/engines/colmap.rs @@ -84,14 +84,14 @@ async fn matching_gpu_options( executable: &Path, manager: &ProcessManager, ) -> Result<(&'static str, &'static str)> { - let help = command_help(executable, "sequential_matcher", manager).await?; + let help = command_help(executable, "exhaustive_matcher", manager).await?; if help.contains("--FeatureMatching.use_gpu") { Ok(("--FeatureMatching.use_gpu", "--FeatureMatching.gpu_index")) } else if help.contains("--SiftMatching.use_gpu") { Ok(("--SiftMatching.use_gpu", "--SiftMatching.gpu_index")) } else { Err(SplatError::UnsupportedEngine( - "COLMAP sequential_matcher 不支持已知的 SIFT GPU 参数".into(), + "COLMAP exhaustive_matcher 不支持已知的 SIFT GPU 参数".into(), )) } } @@ -158,7 +158,7 @@ pub async fn extract_features( .await } -pub async fn match_sequential( +pub async fn match_exhaustive( executable: &Path, database: &Path, log: PathBuf, @@ -169,7 +169,7 @@ pub async fn match_sequential( let (use_gpu_option, gpu_index_option) = matching_gpu_options(executable, manager).await?; run_colmap( executable, - sequential_matching_args(database, gpu_index, use_gpu_option, gpu_index_option), + exhaustive_matching_args(database, gpu_index, use_gpu_option, gpu_index_option), database.parent().unwrap_or(Path::new(".")), log, manager, @@ -205,14 +205,14 @@ fn feature_extraction_args( args } -fn sequential_matching_args( +fn exhaustive_matching_args( database: &Path, gpu_index: Option, use_gpu_option: &str, gpu_index_option: &str, ) -> Vec { let mut args = vec![ - "sequential_matcher".into(), + "exhaustive_matcher".into(), "--database_path".into(), database.into(), use_gpu_option.into(), @@ -223,8 +223,8 @@ fn sequential_matching_args( args.push(index.to_string().into()); } args.extend([ - OsString::from("--SequentialMatching.overlap"), - OsString::from("10"), + OsString::from("--FeatureMatching.guided_matching"), + OsString::from("1"), ]); args } @@ -237,6 +237,18 @@ pub async fn map( log: PathBuf, manager: &ProcessManager, observer: Option, +) -> Result<()> { + map_incremental(executable, database, images, output, log, manager, observer).await +} + +pub async fn map_incremental( + executable: &Path, + database: &Path, + images: &Path, + output: &Path, + log: PathBuf, + manager: &ProcessManager, + observer: Option, ) -> Result<()> { tokio::fs::create_dir_all(output).await?; run_colmap( @@ -258,6 +270,56 @@ pub async fn map( .await } +#[allow(clippy::too_many_arguments)] +pub async fn map_global( + executable: &Path, + database: &Path, + images: &Path, + output: &Path, + log: PathBuf, + manager: &ProcessManager, + observer: Option, + gpu_index: Option, +) -> Result<()> { + let help = command_help(executable, "global_mapper", manager).await?; + if !help.contains("--GlobalMapper.gp_use_gpu") { + return Err(SplatError::UnsupportedEngine( + "当前 COLMAP 不包含 Global Mapper".into(), + )); + } + tokio::fs::create_dir_all(output).await?; + let mut args = vec![ + "global_mapper".into(), + "--database_path".into(), + database.into(), + "--image_path".into(), + images.into(), + "--output_path".into(), + output.into(), + "--GlobalMapper.gp_use_gpu".into(), + (if gpu_index.is_some() { "1" } else { "0" }).into(), + "--GlobalMapper.ba_ceres_use_gpu".into(), + (if gpu_index.is_some() { "1" } else { "0" }).into(), + ]; + if let Some(index) = gpu_index { + args.extend([ + "--GlobalMapper.gp_gpu_index".into(), + index.to_string().into(), + "--GlobalMapper.ba_ceres_gpu_index".into(), + index.to_string().into(), + ]); + } + run_colmap( + executable, + args, + database.parent().unwrap_or(output), + log, + manager, + observer, + ) + .await +} + #[cfg(test)] mod tests { use super::*; @@ -284,7 +346,7 @@ mod tests { .windows(2) .any(|pair| pair == ["--FeatureExtraction.gpu_index", "2"])); - let matching = strings(sequential_matching_args( + let matching = strings(exhaustive_matching_args( Path::new("database.db"), Some(2), "--FeatureMatching.use_gpu", @@ -296,6 +358,9 @@ mod tests { assert!(matching .windows(2) .any(|pair| pair == ["--FeatureMatching.gpu_index", "2"])); + assert!(matching + .windows(2) + .any(|pair| pair == ["--FeatureMatching.guided_matching", "1"])); } #[test] @@ -314,7 +379,7 @@ mod tests { .iter() .any(|arg| arg == "--FeatureExtraction.gpu_index")); - let matching = strings(sequential_matching_args( + let matching = strings(exhaustive_matching_args( Path::new("database.db"), None, "--FeatureMatching.use_gpu", diff --git a/src-tauri/src/engines/health.rs b/src-tauri/src/engines/health.rs index d5506c3e..3782fe1a 100644 --- a/src-tauri/src/engines/health.rs +++ b/src-tauri/src/engines/health.rs @@ -67,6 +67,8 @@ pub struct GpuDeviceInfo { pub name: String, pub driver_version: String, pub compute_capability: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub total_memory_mb: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -747,7 +749,7 @@ async fn probe_gpu_devices() -> std::result::Result, ProbeErr manager.run(ProcessSpec { executable: candidate, args: vec![ - OsString::from("--query-gpu=index,name,driver_version,compute_cap"), + OsString::from("--query-gpu=index,name,driver_version,compute_cap,memory.total"), OsString::from("--format=csv,noheader,nounits"), ], working_directory: None, @@ -768,7 +770,7 @@ fn parse_nvidia_smi_csv(output: &str) -> std::result::Result, let mut devices = Vec::new(); for line in output.lines().filter(|line| !line.trim().is_empty()) { let fields = line.split(',').map(str::trim).collect::>(); - if fields.len() < 4 { + if fields.len() < 5 { return Err(ProbeError::InvalidOutput); } let index = fields[0] @@ -776,9 +778,10 @@ fn parse_nvidia_smi_csv(output: &str) -> std::result::Result, .map_err(|_| ProbeError::InvalidOutput)?; devices.push(GpuDeviceInfo { index, - name: fields[1..fields.len() - 2].join(", "), - driver_version: fields[fields.len() - 2].to_string(), - compute_capability: fields[fields.len() - 1].to_string(), + name: fields[1..fields.len() - 3].join(", "), + driver_version: fields[fields.len() - 3].to_string(), + compute_capability: fields[fields.len() - 2].to_string(), + total_memory_mb: fields[fields.len() - 1].parse().ok(), }); } if devices.is_empty() { @@ -868,18 +871,20 @@ mod tests { name: format!("GPU {index}"), driver_version: driver.into(), compute_capability: compute.into(), + total_memory_mb: Some(8_192), } } #[test] fn parses_nvidia_smi_csv() { let devices = parse_nvidia_smi_csv( - "0, NVIDIA GeForce RTX 3060 Ti, 560.81, 8.6\n1, NVIDIA RTX 4090, 560.81, 8.9\n", + "0, NVIDIA GeForce RTX 3060 Ti, 560.81, 8.6, 8192\n1, NVIDIA RTX 4090, 560.81, 8.9, 24564\n", ) .unwrap(); assert_eq!(devices.len(), 2); assert_eq!(devices[0].name, "NVIDIA GeForce RTX 3060 Ti"); assert_eq!(devices[1].compute_capability, "8.9"); + assert_eq!(devices[0].total_memory_mb, Some(8_192)); } #[test] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2868fa74..b27c2879 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -21,6 +21,7 @@ pub fn run_app() { commands::check_colmap_acceleration, commands::probe_and_plan, commands::start_pipeline, + commands::resume_pipeline, commands::cancel_pipeline, commands::export_ply, commands::get_project_overview, diff --git a/src-tauri/src/pipeline/estimate.rs b/src-tauri/src/pipeline/estimate.rs new file mode 100644 index 00000000..b74a4152 --- /dev/null +++ b/src-tauri/src/pipeline/estimate.rs @@ -0,0 +1,163 @@ +use serde::Serialize; + +use crate::{ + presets::Quality, + video::{FramePlan, VideoInfo}, +}; + +#[derive(Debug, Clone)] +pub struct RuntimeSample { + pub video: VideoInfo, + pub quality: Quality, + pub extracted_frames: u64, + pub duration_ms: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum EstimateConfidence { + Low, + Medium, + High, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeEstimate { + pub estimated_ms: u64, + pub lower_bound_ms: u64, + pub upper_bound_ms: u64, + pub confidence: EstimateConfidence, + pub sample_count: usize, + pub basis: String, +} + +pub fn estimate_runtime( + video: &VideoInfo, + plan: &FramePlan, + quality: Quality, + total_vram_mb: Option, + samples: &[RuntimeSample], +) -> RuntimeEstimate { + let base = base_estimate_ms(video, plan.estimated_frames, quality, total_vram_mb); + let mut calibration = samples + .iter() + .filter(|sample| sample.duration_ms >= 10_000 && sample.extracted_frames > 0) + .map(|sample| { + let expected = base_estimate_ms( + &sample.video, + sample.extracted_frames, + sample.quality, + total_vram_mb, + ); + (sample.duration_ms as f64 / expected.max(1) as f64).clamp(0.60, 2.50) + }) + .collect::>(); + calibration.sort_by(f64::total_cmp); + let sample_count = calibration.len(); + let factor = median(&calibration).unwrap_or(1.0); + let estimated_ms = (base as f64 * factor).round().max(1_000.0) as u64; + let (confidence, lower_factor, upper_factor) = match sample_count { + 0 => (EstimateConfidence::Low, 0.55, 1.75), + 1..=2 => (EstimateConfidence::Low, 0.60, 1.60), + 3..=5 => (EstimateConfidence::Medium, 0.72, 1.38), + _ => (EstimateConfidence::High, 0.82, 1.22), + }; + RuntimeEstimate { + estimated_ms, + lower_bound_ms: (estimated_ms as f64 * lower_factor).round() as u64, + upper_bound_ms: (estimated_ms as f64 * upper_factor).round() as u64, + confidence, + sample_count, + basis: if sample_count == 0 { + "根据视频分辨率、抽帧数、质量档位和显存预算估算;完成任务后会自动校准".into() + } else { + format!( + "根据视频分辨率、抽帧数、质量档位、显存预算和本机 {sample_count} 个已完成任务校准" + ) + }, + } +} + +fn base_estimate_ms( + video: &VideoInfo, + frames: u64, + quality: Quality, + total_vram_mb: Option, +) -> u64 { + let preset = quality.preset().for_vram_mb(total_vram_mb); + let megapixels = video.width as f64 * video.height as f64 / 1_000_000.0; + let pixel_factor = (megapixels / 2.0736).sqrt().clamp(0.65, 1.8); + let frame_count = frames.max(1) as f64; + + // Feature work grows roughly linearly with frames and pixels, while global + // reconstruction/BA grows super-linearly with the number of registered views. + let preparation_ms = 8_000.0 + frame_count * 55.0 * pixel_factor; + let reconstruction_ms = 176.0 * frame_count.powf(1.5); + let resolution_factor = (preset.brush_max_resolution as f64 / 960.0).powf(1.35); + let iteration_factor = preset.brush_iterations as f64 / 6_000.0; + let sh_factor = 1.0 + f64::from(preset.brush_sh_degree.saturating_sub(2)) * 0.08; + let brush_ms = 80_000.0 * resolution_factor * iteration_factor * sh_factor; + (preparation_ms + reconstruction_ms + brush_ms).round() as u64 +} + +fn median(values: &[f64]) -> Option { + match values.len() { + 0 => None, + length if length % 2 == 1 => Some(values[length / 2]), + length => Some((values[length / 2 - 1] + values[length / 2]) / 2.0), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn video() -> VideoInfo { + VideoInfo { + duration: 12.52, + width: 3840, + height: 2160, + fps: 60.0, + total_frames: 752, + codec: "hevc".into(), + rotation: 0, + } + } + + #[test] + fn quality_and_frame_count_increase_the_estimate() { + let video = video(); + let fast = base_estimate_ms(&video, 48, Quality::Fast, Some(8_151)); + let balanced = base_estimate_ms(&video, 72, Quality::Balanced, Some(8_151)); + let high = base_estimate_ms(&video, 120, Quality::High, Some(8_151)); + assert!(fast < balanced && balanced < high); + } + + #[test] + fn completed_local_runs_calibrate_and_narrow_the_range() { + let video = video(); + let plan = FramePlan { + retention_ratio: 0.064, + sampling_fps: 3.83, + estimated_frames: 48, + }; + let sample = RuntimeSample { + video: video.clone(), + quality: Quality::Fast, + extracted_frames: 226, + duration_ms: 858_613, + }; + let estimate = estimate_runtime( + &video, + &plan, + Quality::Fast, + Some(8_151), + &[sample.clone(), sample.clone(), sample], + ); + assert_eq!(estimate.confidence, EstimateConfidence::Medium); + assert_eq!(estimate.sample_count, 3); + assert!(estimate.lower_bound_ms < estimate.estimated_ms); + assert!(estimate.upper_bound_ms > estimate.estimated_ms); + } +} diff --git a/src-tauri/src/pipeline/mod.rs b/src-tauri/src/pipeline/mod.rs index f9ff4f4c..67535172 100644 --- a/src-tauri/src/pipeline/mod.rs +++ b/src-tauri/src/pipeline/mod.rs @@ -1,3 +1,4 @@ +pub mod estimate; pub mod event; pub mod progress; pub mod runner; diff --git a/src-tauri/src/pipeline/runner.rs b/src-tauri/src/pipeline/runner.rs index c1d29e61..5353c43d 100644 --- a/src-tauri/src/pipeline/runner.rs +++ b/src-tauri/src/pipeline/runner.rs @@ -23,7 +23,7 @@ use crate::{ presets::Quality, process::{ProcessManager, ProcessObserver, ProcessUpdate}, project::{ - FrameState, PipelineStateFile, ProjectManager, ProjectMetadata, ProjectOutput, + catalog, FrameState, PipelineStateFile, ProjectManager, ProjectMetadata, ProjectOutput, ProjectPaths, ProjectStatus, }, reconstruction::{ @@ -310,15 +310,57 @@ impl PipelineRunner { let acceleration = self.verify_pipeline_engines().await?; self.events.acceleration(acceleration.clone()); let (paths, mut metadata) = project_manager.create(input, quality).await?; + let state = PipelineStateFile::created(quality); + self.execute_project(project_manager, paths, &mut metadata, state, &acceleration) + .await + } + + pub async fn resume(&self, project_id: uuid::Uuid) -> Result { + let acceleration = self.verify_pipeline_engines().await?; + self.events.acceleration(acceleration.clone()); + let (project, mut metadata) = catalog::load_registered_project(project_id).await?; + if metadata.status == ProjectStatus::Completed || project.join("final.ply").is_file() { + return Err(SplatError::Process("该项目已经完成,无需继续".into())); + } + if !metadata.source_path.is_file() { + return Err(SplatError::Process("项目源视频缺失,无法继续".into())); + } + let paths = ProjectPaths::existing(project_id, project.clone()); + let project_manager = ProjectManager::with_root( + project + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| project.clone()), + ); + let state = project_manager.read_state(&paths.state).await?; + if state.preset != metadata.quality { + return Err(SplatError::Process( + "项目档位与检查点不一致,无法安全继续".into(), + )); + } + self.execute_project(project_manager, paths, &mut metadata, state, &acceleration) + .await + } + + async fn execute_project( + &self, + project_manager: ProjectManager, + paths: ProjectPaths, + metadata: &mut ProjectMetadata, + state: PipelineStateFile, + acceleration: &crate::engines::ColmapAccelerationStatus, + ) -> Result { let started = Instant::now(); + let previous_duration = metadata.duration_ms.unwrap_or(0); + metadata.status = ProjectStatus::Running; + metadata.started_at = Some(Utc::now()); + metadata.completed_at = None; + metadata.failure_message = None; + project_manager + .write_metadata(&paths.metadata, metadata) + .await?; let result = self - .run_project( - &project_manager, - &paths, - &mut metadata, - quality, - &acceleration, - ) + .run_project(&project_manager, &paths, metadata, state, acceleration) .await; if let Err(error) = &result { @@ -329,12 +371,16 @@ impl PipelineRunner { ProjectStatus::Failed }; metadata.completed_at = Some(Utc::now()); - metadata.duration_ms = Some(started.elapsed().as_millis() as u64); + metadata.duration_ms = + Some(previous_duration.saturating_add(started.elapsed().as_millis() as u64)); metadata.failure_message = Some(error.to_string()); let _ = project_manager - .write_metadata(&paths.metadata, &metadata) + .write_metadata(&paths.metadata, metadata) .await; - let mut state = PipelineStateFile::created(quality); + let mut state = project_manager + .read_state(&paths.state) + .await + .unwrap_or_else(|_| PipelineStateFile::created(metadata.quality)); state.stage = if cancelled { PipelineStage::Cancelled } else { @@ -350,25 +396,45 @@ impl PipelineRunner { project_manager: &ProjectManager, paths: &ProjectPaths, metadata: &mut ProjectMetadata, - quality: Quality, + mut state: PipelineStateFile, acceleration: &crate::engines::ColmapAccelerationStatus, ) -> Result { - let mut state = PipelineStateFile::created(quality); - let prepared = self - .prepare_frames( - &metadata.source_path, - quality, - &paths.frames, - Some(&paths.logs), - ) - .await?; - let source_duration_seconds = prepared.video.duration; - state.video = Some(prepared.video); - let mut frames = FrameState::from(&prepared.plan); - frames.extracted_frames = Some(prepared.extracted_frames); - state.frames = Some(frames); - state.stage = PipelineStage::ExtractingFrames; + let quality = metadata.quality; + normalize_checkpoints(paths, &mut state).await?; project_manager.write_state(&paths.state, &state).await?; + let prepared = + if let Some(prepared) = prepared_frames_from_checkpoint(paths, &state).await? { + self.events.stage( + PipelineStage::ExtractingFrames, + 1.0, + format!("已复用 {} 帧检查点", prepared.extracted_frames), + ); + prepared + } else { + reset_directory(&paths.frames).await?; + reset_directory(&paths.colmap).await?; + reset_directory(&paths.brush).await?; + let prepared = self + .prepare_frames( + &metadata.source_path, + quality, + &paths.frames, + Some(&paths.logs), + ) + .await?; + state.video = Some(prepared.video.clone()); + let mut frames = FrameState::from(&prepared.plan); + frames.extracted_frames = Some(prepared.extracted_frames); + state.frames = Some(frames); + state.features_complete = false; + state.matching_complete = false; + state.reconstruction_complete = false; + state.brush_complete = false; + state.stage = PipelineStage::ExtractingFrames; + project_manager.write_state(&paths.state, &state).await?; + prepared + }; + let source_duration_seconds = prepared.video.duration; let database = paths.colmap.join("database.db"); let sparse = paths.colmap.join("sparse"); @@ -381,82 +447,195 @@ impl PipelineRunner { let backend_label = if acceleration.use_gpu() { "GPU" } else { "CPU" }; let gpu_index = acceleration.gpu_index(); - self.events.stage( - PipelineStage::ExtractingFeatures, - 0.0, - format!("COLMAP 正在使用 {backend_label} 提取特征"), - ); - colmap::extract_features( - &self.engines.colmap, - &database, - colmap_images, - colmap_log.clone(), - &self.process_manager, - Some(self.process_observer( + if state.features_complete { + self.events.stage( PipelineStage::ExtractingFeatures, - PipelineEngine::Colmap, - Some(prepared.extracted_frames), - ObserverMode::BracketProgress, - )), - gpu_index, - ) - .await?; - state.stage = PipelineStage::ExtractingFeatures; - state.features_complete = true; - project_manager.write_state(&paths.state, &state).await?; - self.events.stage( - PipelineStage::ExtractingFeatures, - 1.0, - format!("{backend_label} 特征提取完成"), - ); + 1.0, + "已复用特征提取检查点", + ); + } else { + reset_directory(&paths.colmap).await?; + self.events.stage( + PipelineStage::ExtractingFeatures, + 0.0, + format!("COLMAP 正在使用 {backend_label} 提取特征"), + ); + colmap::extract_features( + &self.engines.colmap, + &database, + colmap_images, + colmap_log.clone(), + &self.process_manager, + Some(self.process_observer( + PipelineStage::ExtractingFeatures, + PipelineEngine::Colmap, + Some(prepared.extracted_frames), + ObserverMode::BracketProgress, + )), + gpu_index, + ) + .await?; + state.stage = PipelineStage::ExtractingFeatures; + state.features_complete = true; + project_manager.write_state(&paths.state, &state).await?; + self.events.stage( + PipelineStage::ExtractingFeatures, + 1.0, + format!("{backend_label} 特征提取完成"), + ); + } - self.events.stage( - PipelineStage::Matching, - 0.0, - format!("COLMAP 正在进行 {backend_label} 顺序匹配"), - ); - colmap::match_sequential( - &self.engines.colmap, - &database, - colmap_log.clone(), - &self.process_manager, - Some(self.process_observer( + if state.matching_complete { + self.events + .stage(PipelineStage::Matching, 1.0, "已复用跨视角匹配检查点"); + } else { + self.events.stage( PipelineStage::Matching, - PipelineEngine::Colmap, - Some(prepared.extracted_frames), - ObserverMode::BracketProgress, - )), - gpu_index, - ) - .await?; - state.stage = PipelineStage::Matching; - state.matching_complete = true; - project_manager.write_state(&paths.state, &state).await?; - self.events - .stage(PipelineStage::Matching, 1.0, "顺序匹配完成"); + 0.0, + format!("COLMAP 正在进行 {backend_label} 跨视角匹配"), + ); + colmap::match_exhaustive( + &self.engines.colmap, + &database, + colmap_log.clone(), + &self.process_manager, + Some(self.process_observer( + PipelineStage::Matching, + PipelineEngine::Colmap, + Some(prepared.extracted_frames), + ObserverMode::BracketProgress, + )), + gpu_index, + ) + .await?; + state.stage = PipelineStage::Matching; + state.matching_complete = true; + state.matching_strategy_version = 1; + project_manager.write_state(&paths.state, &state).await?; + self.events + .stage(PipelineStage::Matching, 1.0, "跨视角匹配完成"); + } - self.events - .stage(PipelineStage::Reconstructing, 0.0, "正在增量重建相机轨迹"); - colmap::map( - &self.engines.colmap, - &database, - colmap_images, - &sparse, - colmap_log, - &self.process_manager, - Some(self.process_observer( + if state.reconstruction_complete { + self.events + .stage(PipelineStage::Reconstructing, 1.0, "已复用相机重建检查点"); + } else { + reset_directory(&sparse).await?; + let global_sparse = sparse.join("global"); + self.events.stage( PipelineStage::Reconstructing, - PipelineEngine::Colmap, - Some(prepared.extracted_frames), - ObserverMode::Mapper, - )), - ) - .await?; - state.stage = PipelineStage::Reconstructing; - state.reconstruction_complete = true; - project_manager.write_state(&paths.state, &state).await?; - self.events - .stage(PipelineStage::Reconstructing, 1.0, "增量重建完成"); + 0.0, + "正在使用 Global Mapper 快速重建相机轨迹", + ); + let global_result = colmap::map_global( + &self.engines.colmap, + &database, + colmap_images, + &global_sparse, + colmap_log.clone(), + &self.process_manager, + Some(self.process_observer( + PipelineStage::Reconstructing, + PipelineEngine::Colmap, + Some(prepared.extracted_frames), + ObserverMode::Mapper, + )), + gpu_index, + ) + .await; + + let global_report = match global_result { + Ok(()) => best_sparse_model(&paths.frames, &global_sparse).ok(), + Err(SplatError::Cancelled) => return Err(SplatError::Cancelled), + Err(error) => { + self.events.send( + PipelineStage::Reconstructing, + Some(PipelineEngine::Colmap), + EventKind::Log, + EventLevel::Warning, + None, + false, + format!("Global Mapper 不可用,将回退增量重建:{error}"), + None, + None, + None, + ); + None + } + }; + let global_is_good = global_report + .as_ref() + .is_some_and(|(_, report)| report.quality == ReconstructionQuality::Good); + if !global_is_good { + if let Some((_, report)) = &global_report { + self.events.send( + PipelineStage::Reconstructing, + Some(PipelineEngine::Colmap), + EventKind::Log, + EventLevel::Warning, + None, + false, + format!( + "Global Mapper 注册率 {:.1}%,正在回退增量重建", + report.registered_ratio * 100.0 + ), + None, + None, + None, + ); + } + let incremental_sparse = sparse.join("incremental"); + self.events.stage( + PipelineStage::Reconstructing, + 0.05, + "正在使用增量 Mapper 进行稳健回退", + ); + let incremental_result = colmap::map_incremental( + &self.engines.colmap, + &database, + colmap_images, + &incremental_sparse, + colmap_log, + &self.process_manager, + Some(self.process_observer( + PipelineStage::Reconstructing, + PipelineEngine::Colmap, + Some(prepared.extracted_frames), + ObserverMode::Mapper, + )), + ) + .await; + if let Err(error) = incremental_result { + if matches!(error, SplatError::Cancelled) || global_report.is_none() { + return Err(error); + } + self.events.send( + PipelineStage::Reconstructing, + Some(PipelineEngine::Colmap), + EventKind::Log, + EventLevel::Warning, + None, + false, + "增量重建失败,继续使用可用的 Global Mapper 结果", + None, + None, + None, + ); + } + } + state.stage = PipelineStage::Reconstructing; + state.reconstruction_complete = true; + project_manager.write_state(&paths.state, &state).await?; + self.events.stage( + PipelineStage::Reconstructing, + 1.0, + if global_is_good { + "Global Mapper 快速重建完成" + } else { + "相机轨迹重建完成" + }, + ); + } self.events.stage( PipelineStage::ValidatingReconstruction, @@ -472,7 +651,7 @@ impl PipelineRunner { report.registered_ratio * 100.0 ))); } - let warning = (report.quality == ReconstructionQuality::Warning).then(|| { + let mut warning = (report.quality == ReconstructionQuality::Warning).then(|| { format!( "注册率 {:.1}%:低于 80%,结果质量可能受影响", report.registered_ratio * 100.0 @@ -487,43 +666,101 @@ impl PipelineRunner { ), ); - let dataset = prepare_brush_dataset(&paths.brush, &paths.frames, &model).await?; - let preset = quality.preset(); - self.events.send( - PipelineStage::TrainingSplats, - Some(PipelineEngine::Brush), - EventKind::Stage, - EventLevel::Info, - None, - true, - format!( - "Brush 训练开始(使用可用图形后端)· {} iterations · 最大分辨率 {}", - preset.brush_iterations, preset.brush_max_resolution - ), - Some(0), - Some(preset.brush_iterations as u64), - Some("iterations"), - ); - let candidate = brush::train( - &self.engines.brush, - &dataset, - &paths.brush, - preset, - paths.logs.join("brush.log"), - &self.process_manager, - Some(self.process_observer( + let total_vram_mb = acceleration + .device + .as_ref() + .and_then(|device| device.total_memory_mb); + let preset = quality.preset().for_vram_mb(total_vram_mb); + let candidate = if state.brush_complete { + self.events.stage( + PipelineStage::TrainingSplats, + 1.0, + "已复用 Brush 训练检查点", + ); + brush_candidate(&paths.brush) + .ok_or_else(|| SplatError::Process("Brush 检查点文件缺失,无法继续发布".into()))? + } else { + reset_directory(&paths.brush).await?; + let dataset = prepare_brush_dataset(&paths.brush, &paths.frames, &model).await?; + self.events.send( + PipelineStage::TrainingSplats, + Some(PipelineEngine::Brush), + EventKind::Stage, + EventLevel::Info, + None, + true, + format!( + "Brush 训练开始 · {} iterations · 最大分辨率 {} · 最多 {} Splats · SH {}", + preset.brush_iterations, + preset.brush_max_resolution, + preset.brush_max_splats, + preset.brush_sh_degree + ), + Some(0), + Some(preset.brush_iterations as u64), + Some("iterations"), + ); + let observer = self.process_observer( PipelineStage::TrainingSplats, PipelineEngine::Brush, Some(preset.brush_iterations as u64), ObserverMode::Brush, - )), - ) - .await?; - state.stage = PipelineStage::TrainingSplats; - state.brush_complete = true; - project_manager.write_state(&paths.state, &state).await?; - self.events - .stage(PipelineStage::TrainingSplats, 1.0, "Brush 训练完成"); + ); + let first_attempt = brush::train( + &self.engines.brush, + &dataset, + &paths.brush, + preset, + paths.logs.join("brush.log"), + &self.process_manager, + Some(observer.clone()), + ) + .await; + let candidate = match first_attempt { + Ok(candidate) => candidate, + Err(error) if brush::is_out_of_memory(&error) => { + let retry = preset.degraded_for_oom(); + warning = Some(match warning { + Some(existing) => { + format!("{existing};训练曾遇到显存不足,已自动降低分辨率和 Splat 上限") + } + None => "训练曾遇到显存不足,已自动降低分辨率和 Splat 上限".into(), + }); + self.events.send( + PipelineStage::TrainingSplats, + Some(PipelineEngine::Brush), + EventKind::Stage, + EventLevel::Warning, + None, + true, + format!( + "显存不足,自动重试 · 最大分辨率 {} · 最多 {} Splats", + retry.brush_max_resolution, retry.brush_max_splats + ), + Some(0), + Some(retry.brush_iterations as u64), + Some("iterations"), + ); + brush::train( + &self.engines.brush, + &dataset, + &paths.brush, + retry, + paths.logs.join("brush.log"), + &self.process_manager, + Some(observer), + ) + .await? + } + Err(error) => return Err(error), + }; + state.stage = PipelineStage::TrainingSplats; + state.brush_complete = true; + project_manager.write_state(&paths.state, &state).await?; + self.events + .stage(PipelineStage::TrainingSplats, 1.0, "Brush 训练完成"); + candidate + }; self.events .stage(PipelineStage::Exporting, 0.0, "正在校验并发布 final.ply"); @@ -534,10 +771,12 @@ impl PipelineRunner { project_manager.write_state(&paths.state, &state).await?; let completed_at = Utc::now(); - let duration_ms = metadata - .started_at - .map(|started| (completed_at - started).num_milliseconds().max(0) as u64) - .unwrap_or(0); + let duration_ms = metadata.duration_ms.unwrap_or(0).saturating_add( + metadata + .started_at + .map(|started| (completed_at - started).num_milliseconds().max(0) as u64) + .unwrap_or(0), + ); metadata.status = ProjectStatus::Completed; metadata.completed_at = Some(completed_at); metadata.duration_ms = Some(duration_ms); @@ -751,13 +990,122 @@ fn format_duration(milliseconds: u64) -> String { ) } +fn checkpoint_stage(state: &PipelineStateFile) -> PipelineStage { + if state.brush_complete { + PipelineStage::TrainingSplats + } else if state.reconstruction_complete { + PipelineStage::Reconstructing + } else if state.matching_complete { + PipelineStage::Matching + } else if state.features_complete { + PipelineStage::ExtractingFeatures + } else if state + .frames + .as_ref() + .and_then(|frames| frames.extracted_frames) + .is_some_and(|count| count > 0) + { + PipelineStage::ExtractingFrames + } else { + PipelineStage::Created + } +} + +async fn normalize_checkpoints(paths: &ProjectPaths, state: &mut PipelineStateFile) -> Result<()> { + let frames_complete = prepared_frames_from_checkpoint(paths, state) + .await? + .is_some(); + if !frames_complete { + state.video = None; + state.frames = None; + } + + state.features_complete = + frames_complete && state.features_complete && paths.colmap.join("database.db").is_file(); + state.matching_complete = + state.features_complete && state.matching_complete && state.matching_strategy_version == 1; + state.reconstruction_complete = state.matching_complete + && state.reconstruction_complete + && best_sparse_model(&paths.frames, &paths.colmap.join("sparse")).is_ok(); + state.brush_complete = state.reconstruction_complete + && state.brush_complete + && brush_candidate(&paths.brush) + .and_then(|path| inspect_gaussian_ply(&path).ok()) + .is_some(); + state.stage = checkpoint_stage(state); + Ok(()) +} + +async fn prepared_frames_from_checkpoint( + paths: &ProjectPaths, + state: &PipelineStateFile, +) -> Result> { + let Some(video) = state.video.clone() else { + return Ok(None); + }; + let Some(frames) = state.frames.as_ref() else { + return Ok(None); + }; + let Some(extracted_frames) = frames.extracted_frames.filter(|count| *count > 0) else { + return Ok(None); + }; + let mut actual_frames = 0; + if paths.frames.is_dir() { + let mut entries = tokio::fs::read_dir(&paths.frames).await?; + while let Some(entry) = entries.next_entry().await? { + if entry + .path() + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("jpg")) + { + actual_frames += 1; + } + } + } + if actual_frames != extracted_frames { + return Ok(None); + } + Ok(Some(PreparedFrames { + video, + plan: FramePlan { + retention_ratio: frames.retention_ratio, + sampling_fps: frames.sampling_fps, + estimated_frames: frames.estimated_frames, + }, + extracted_frames, + })) +} + +fn brush_candidate(root: &Path) -> Option { + [root.join("final.ply.tmp"), root.join("final.ply.tmp.ply")] + .into_iter() + .find(|path| path.is_file()) +} + +async fn reset_directory(path: &Path) -> Result<()> { + if path.exists() { + tokio::fs::remove_dir_all(path).await?; + } + tokio::fs::create_dir_all(path).await?; + Ok(()) +} + fn best_sparse_model(frames: &Path, sparse: &Path) -> Result<(PathBuf, ReconstructionReport)> { let mut best: Option<(PathBuf, ReconstructionReport)> = None; + let mut candidates = vec![sparse.to_path_buf()]; for entry in std::fs::read_dir(sparse)? { let path = entry?.path(); - if !path.is_dir() { - continue; + if path.is_dir() { + candidates.push(path.clone()); + for nested in std::fs::read_dir(path)? { + let nested = nested?.path(); + if nested.is_dir() { + candidates.push(nested); + } + } } + } + for path in candidates { if let Ok(report) = ReconstructionValidator::validate(frames, &path) { if best .as_ref() @@ -809,6 +1157,68 @@ mod tests { assert_eq!(parse_ffmpeg_frame("progress=continue"), None); } + #[test] + fn reports_the_latest_durable_checkpoint_instead_of_terminal_status() { + let mut state = PipelineStateFile::created(Quality::Balanced); + state.stage = PipelineStage::Cancelled; + assert_eq!(checkpoint_stage(&state), PipelineStage::Created); + + state.frames = Some(FrameState { + retention_ratio: 0.5, + sampling_fps: 15.0, + estimated_frames: 100, + extracted_frames: Some(100), + }); + state.features_complete = true; + state.matching_complete = true; + assert_eq!(checkpoint_stage(&state), PipelineStage::Matching); + + state.reconstruction_complete = true; + state.brush_complete = true; + assert_eq!(checkpoint_stage(&state), PipelineStage::TrainingSplats); + } + + #[tokio::test] + async fn frame_checkpoint_requires_every_recorded_frame() { + let temporary = tempfile::tempdir().unwrap(); + let paths = ProjectPaths::existing(uuid::Uuid::nil(), temporary.path().to_path_buf()); + tokio::fs::create_dir_all(&paths.frames).await.unwrap(); + tokio::fs::write(paths.frames.join("frame_000001.jpg"), b"one") + .await + .unwrap(); + tokio::fs::write(paths.frames.join("frame_000002.jpg"), b"two") + .await + .unwrap(); + let mut state = PipelineStateFile::created(Quality::Balanced); + state.video = Some(VideoInfo { + duration: 1.0, + width: 1920, + height: 1080, + fps: 30.0, + total_frames: 30, + codec: "h264".into(), + rotation: 0, + }); + state.frames = Some(FrameState { + retention_ratio: 0.5, + sampling_fps: 15.0, + estimated_frames: 2, + extracted_frames: Some(2), + }); + + assert!(prepared_frames_from_checkpoint(&paths, &state) + .await + .unwrap() + .is_some()); + tokio::fs::remove_file(paths.frames.join("frame_000002.jpg")) + .await + .unwrap(); + assert!(prepared_frames_from_checkpoint(&paths, &state) + .await + .unwrap() + .is_none()); + } + #[test] fn parses_colmap_file_progress() { assert_eq!( diff --git a/src-tauri/src/presets/quality.rs b/src-tauri/src/presets/quality.rs index 4276de61..0545692c 100644 --- a/src-tauri/src/presets/quality.rs +++ b/src-tauri/src/presets/quality.rs @@ -12,33 +12,96 @@ pub enum Quality { #[derive(Debug, Clone, Copy, PartialEq)] pub struct QualityPreset { - pub frame_retention_ratio: f64, + pub target_sampling_fps: f64, + pub minimum_frames: u64, + pub maximum_frames: u64, pub brush_iterations: usize, pub brush_max_resolution: u32, + pub brush_max_splats: u32, + pub brush_sh_degree: u8, } impl Quality { pub const fn preset(self) -> QualityPreset { match self { Self::Fast => QualityPreset { - frame_retention_ratio: 0.30, - brush_iterations: 8_000, - brush_max_resolution: 1_200, + target_sampling_fps: 3.0, + minimum_frames: 48, + maximum_frames: 120, + brush_iterations: 6_000, + brush_max_resolution: 960, + brush_max_splats: 600_000, + brush_sh_degree: 2, }, Self::Balanced => QualityPreset { - frame_retention_ratio: 0.50, - brush_iterations: 15_000, - brush_max_resolution: 1_600, + target_sampling_fps: 5.0, + minimum_frames: 72, + maximum_frames: 240, + brush_iterations: 10_000, + brush_max_resolution: 1_200, + brush_max_splats: 1_000_000, + brush_sh_degree: 3, }, Self::High => QualityPreset { - frame_retention_ratio: 1.00, - brush_iterations: 30_000, - brush_max_resolution: 2_000, + target_sampling_fps: 8.0, + minimum_frames: 120, + maximum_frames: 400, + brush_iterations: 15_000, + brush_max_resolution: 1_600, + brush_max_splats: 1_500_000, + brush_sh_degree: 3, }, } } } +impl QualityPreset { + /// Keeps enough headroom for the desktop compositor and WebGPU allocations on + /// small laptop GPUs. Unknown/non-NVIDIA devices use the conservative 8 GB tier. + pub const fn for_vram_mb(mut self, total_vram_mb: Option) -> Self { + let vram = match total_vram_mb { + Some(value) => value, + None => 8_192, + }; + if vram <= 6_500 { + self.brush_max_resolution = if self.brush_max_resolution > 960 { + 960 + } else { + self.brush_max_resolution + }; + self.brush_max_splats = if self.brush_max_splats > 600_000 { + 600_000 + } else { + self.brush_max_splats + }; + } else if vram <= 9_000 { + self.brush_max_resolution = if self.brush_max_resolution > 1_200 { + 1_200 + } else { + self.brush_max_resolution + }; + self.brush_max_splats = if self.brush_max_splats > 1_000_000 { + 1_000_000 + } else { + self.brush_max_splats + }; + } + self + } + + pub const fn degraded_for_oom(mut self) -> Self { + self.brush_max_resolution = self.brush_max_resolution * 3 / 4; + if self.brush_max_resolution < 720 { + self.brush_max_resolution = 720; + } + self.brush_max_splats /= 2; + if self.brush_max_splats < 300_000 { + self.brush_max_splats = 300_000; + } + self + } +} + impl fmt::Display for Quality { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(match self { @@ -68,11 +131,22 @@ mod tests { #[test] fn presets_are_centralized_and_exact() { - assert_eq!(Quality::Fast.preset().frame_retention_ratio, 0.30); - assert_eq!(Quality::Balanced.preset().frame_retention_ratio, 0.50); - assert_eq!(Quality::High.preset().frame_retention_ratio, 1.00); - assert_eq!(Quality::Fast.preset().brush_iterations, 8_000); - assert_eq!(Quality::Balanced.preset().brush_max_resolution, 1_600); + assert_eq!(Quality::Fast.preset().target_sampling_fps, 3.0); + assert_eq!(Quality::Balanced.preset().maximum_frames, 240); + assert_eq!(Quality::High.preset().minimum_frames, 120); + assert_eq!(Quality::Fast.preset().brush_iterations, 6_000); + assert_eq!(Quality::Balanced.preset().brush_max_resolution, 1_200); + assert_eq!(Quality::High.preset().brush_max_splats, 1_500_000); + } + + #[test] + fn small_vram_profile_caps_expensive_brush_settings() { + let preset = Quality::High.preset().for_vram_mb(Some(8_151)); + assert_eq!(preset.brush_max_resolution, 1_200); + assert_eq!(preset.brush_max_splats, 1_000_000); + let retry = preset.degraded_for_oom(); + assert_eq!(retry.brush_max_resolution, 900); + assert_eq!(retry.brush_max_splats, 500_000); } #[test] diff --git a/src-tauri/src/project/catalog.rs b/src-tauri/src/project/catalog.rs index fc56498b..6984f59f 100644 --- a/src-tauri/src/project/catalog.rs +++ b/src-tauri/src/project/catalog.rs @@ -9,10 +9,52 @@ use uuid::Uuid; use crate::{ error::{Result, SplatError}, + pipeline::estimate::RuntimeSample, project::{manager::atomic_write_json, ProjectMetadata, ProjectStatus, PROJECT_APP_ID}, reconstruction::ply::inspect_gaussian_ply, }; +pub async fn runtime_samples() -> Vec { + let Ok(index) = load_index().await else { + return Vec::new(); + }; + let mut samples = Vec::new(); + for item in index.projects.into_iter().rev().take(20) { + let Ok(metadata_bytes) = tokio::fs::read(item.path.join("project.json")).await else { + continue; + }; + let Ok(metadata) = serde_json::from_slice::(&metadata_bytes) else { + continue; + }; + let Some(duration_ms) = metadata + .duration_ms + .filter(|_| metadata.status == ProjectStatus::Completed) + else { + continue; + }; + let Ok(state_bytes) = tokio::fs::read(item.path.join("state.json")).await else { + continue; + }; + let Ok(state) = serde_json::from_slice::(&state_bytes) + else { + continue; + }; + let (Some(video), Some(frames)) = (state.video, state.frames) else { + continue; + }; + let Some(extracted_frames) = frames.extracted_frames.filter(|count| *count > 0) else { + continue; + }; + samples.push(RuntimeSample { + video, + quality: metadata.quality, + extracted_frames, + duration_ms, + }); + } + samples +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AppSettings { diff --git a/src-tauri/src/project/manager.rs b/src-tauri/src/project/manager.rs index 51f6e309..2ca0768d 100644 --- a/src-tauri/src/project/manager.rs +++ b/src-tauri/src/project/manager.rs @@ -24,6 +24,26 @@ pub struct ProjectPaths { pub state: PathBuf, } +impl ProjectPaths { + pub fn existing(id: Uuid, project: PathBuf) -> Self { + let source = project.join("source"); + let work = project.join("work"); + Self { + id, + metadata: project.join("project.json"), + output: project.clone(), + frames: work.join("frames"), + colmap: work.join("colmap"), + brush: work.join("brush"), + logs: project.join("logs"), + state: project.join("state.json"), + project, + source, + work, + } + } +} + #[derive(Debug, Clone)] pub struct ProjectManager { projects_root: PathBuf, @@ -147,6 +167,9 @@ impl ProjectManager { pub async fn write_state(&self, path: &Path, state: &PipelineStateFile) -> Result<()> { atomic_write_json(path, state).await } + pub async fn read_state(&self, path: &Path) -> Result { + Ok(serde_json::from_slice(&tokio::fs::read(path).await?)?) + } pub async fn write_metadata(&self, path: &Path, metadata: &ProjectMetadata) -> Result<()> { atomic_write_json(path, metadata).await } @@ -218,12 +241,12 @@ fn atomic_replace(source: &Path, destination: &Path) -> Result<()> { use windows_sys::Win32::Storage::FileSystem::{ MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, }; - let source = source + let source_wide = source .as_os_str() .encode_wide() .chain(iter::once(0)) .collect::>(); - let destination = destination + let destination_wide = destination .as_os_str() .encode_wide() .chain(iter::once(0)) @@ -231,16 +254,57 @@ fn atomic_replace(source: &Path, destination: &Path) -> Result<()> { // SAFETY: Both paths are owned, NUL-terminated UTF-16 buffers that remain live for the call. let result = unsafe { MoveFileExW( - source.as_ptr(), - destination.as_ptr(), + source_wide.as_ptr(), + destination_wide.as_ptr(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, ) }; - if result == 0 { - Err(std::io::Error::last_os_error().into()) + if result != 0 { + return Ok(()); + } + let error = std::io::Error::last_os_error(); + if error.kind() == std::io::ErrorKind::CrossesDevices { + copy_replace_for_encrypted_directory(source, destination) } else { + Err(error.into()) + } +} + +#[cfg(windows)] +fn copy_replace_for_encrypted_directory(source: &Path, destination: &Path) -> Result<()> { + let backup = destination.with_extension("json.bak"); + + if backup.exists() { + std::fs::remove_file(&backup)?; + } + if destination.exists() { + std::fs::copy(destination, &backup)?; + std::fs::OpenOptions::new() + .write(true) + .open(&backup)? + .sync_all()?; + } + + let replacement = (|| -> std::io::Result<()> { + std::fs::copy(source, destination)?; + std::fs::OpenOptions::new() + .write(true) + .open(destination)? + .sync_all()?; + std::fs::remove_file(source)?; Ok(()) + })(); + + if let Err(error) = replacement { + if backup.is_file() { + let _ = std::fs::copy(&backup, destination); + } + return Err(error.into()); + } + if backup.is_file() { + std::fs::remove_file(backup)?; } + Ok(()) } #[cfg(not(windows))] @@ -284,6 +348,24 @@ mod tests { serde_json::from_slice(&tokio::fs::read(path).await.unwrap()).unwrap(); assert_eq!(value["value"], 2); } + + #[cfg(windows)] + #[test] + fn copy_replacement_keeps_latest_data_and_cleans_temporary_files() { + let temporary = tempfile::tempdir().unwrap(); + let source = temporary.path().join("settings.json.tmp"); + let destination = temporary.path().join("settings.json"); + let backup = temporary.path().join("settings.json.bak"); + std::fs::write(&source, br#"{"value":2}"#).unwrap(); + std::fs::write(&destination, br#"{"value":1}"#).unwrap(); + + copy_replace_for_encrypted_directory(&source, &destination).unwrap(); + + assert_eq!(std::fs::read(&destination).unwrap(), br#"{"value":2}"#); + assert!(!source.exists()); + assert!(!backup.exists()); + } + #[tokio::test] async fn creates_self_contained_unicode_project() { let temporary = tempfile::tempdir().unwrap(); diff --git a/src-tauri/src/project/metadata.rs b/src-tauri/src/project/metadata.rs index 90169f9c..bdf05e8a 100644 --- a/src-tauri/src/project/metadata.rs +++ b/src-tauri/src/project/metadata.rs @@ -147,6 +147,8 @@ pub struct PipelineStateFile { pub frames: Option, pub features_complete: bool, pub matching_complete: bool, + #[serde(default)] + pub matching_strategy_version: u32, pub reconstruction_complete: bool, pub brush_complete: bool, } @@ -160,6 +162,7 @@ impl PipelineStateFile { frames: None, features_complete: false, matching_complete: false, + matching_strategy_version: 1, reconstruction_complete: false, brush_complete: false, } diff --git a/src-tauri/src/video/frame_plan.rs b/src-tauri/src/video/frame_plan.rs index 8e11475e..cf160d95 100644 --- a/src-tauri/src/video/frame_plan.rs +++ b/src-tauri/src/video/frame_plan.rs @@ -19,11 +19,23 @@ pub struct UniformRatioFrameSelection; impl FrameSelectionStrategy for UniformRatioFrameSelection { fn create_plan(&self, video: &VideoInfo, preset: &QualityPreset) -> FramePlan { + let duration = video.duration.max(0.001); + let desired_frames = (duration * preset.target_sampling_fps) + .round() + .max(preset.minimum_frames as f64) + .min(preset.maximum_frames as f64) + .min(video.total_frames as f64) + .max(1.0) as u64; + let sampling_fps = (desired_frames as f64 / duration).min(video.fps); + let retention_ratio = if video.total_frames == 0 { + 0.0 + } else { + desired_frames as f64 / video.total_frames as f64 + }; FramePlan { - retention_ratio: preset.frame_retention_ratio, - sampling_fps: video.fps * preset.frame_retention_ratio, - estimated_frames: ((video.total_frames as f64) * preset.frame_retention_ratio).round() - as u64, + retention_ratio, + sampling_fps, + estimated_frames: desired_frames, } } } @@ -46,50 +58,50 @@ mod tests { } #[test] - fn calculates_required_sampling_rates() { + fn targets_useful_sampling_rates_instead_of_source_percentages() { let strategy = UniformRatioFrameSelection; let video = thirty_fps_video(); assert_eq!( strategy .create_plan(&video, &Quality::Fast.preset()) .sampling_fps, - 9.0 + 2.0 ); assert_eq!( strategy .create_plan(&video, &Quality::Balanced.preset()) .sampling_fps, - 15.0 + 4.0 ); assert_eq!( strategy .create_plan(&video, &Quality::High.preset()) .sampling_fps, - 30.0 + 6.666666666666667 ); } #[test] - fn estimates_frames_without_a_cap() { + fn keeps_frame_counts_inside_each_quality_budget() { let strategy = UniformRatioFrameSelection; let video = thirty_fps_video(); assert_eq!( strategy .create_plan(&video, &Quality::Fast.preset()) .estimated_frames, - 540 + 120 ); assert_eq!( strategy .create_plan(&video, &Quality::Balanced.preset()) .estimated_frames, - 900 + 240 ); assert_eq!( strategy .create_plan(&video, &Quality::High.preset()) .estimated_frames, - 1800 + 400 ); let long_video = VideoInfo { @@ -100,7 +112,21 @@ mod tests { strategy .create_plan(&long_video, &Quality::High.preset()) .estimated_frames, - 180_000 + 400 ); } + + #[test] + fn short_video_meets_the_minimum_without_duplicating_source_frames() { + let strategy = UniformRatioFrameSelection; + let video = VideoInfo { + duration: 10.0, + total_frames: 300, + ..thirty_fps_video() + }; + let plan = strategy.create_plan(&video, &Quality::Fast.preset()); + assert_eq!(plan.estimated_frames, 48); + assert_eq!(plan.sampling_fps, 4.8); + assert_eq!(plan.retention_ratio, 0.16); + } } diff --git a/src/app/App.preview.test.tsx b/src/app/App.preview.test.tsx index ecad0f11..9c6fddae 100644 --- a/src/app/App.preview.test.tsx +++ b/src/app/App.preview.test.tsx @@ -10,6 +10,7 @@ import type { ProjectSummary } from "../types/pipeline"; const mocks = vi.hoisted(() => ({ prepareGaussianPreview: vi.fn(), releaseGaussianPreview: vi.fn(), + resumePipeline: vi.fn(), getProjectOverview: vi.fn(), initializeTelemetry: vi.fn(), setTelemetryConsent: vi.fn(), @@ -26,6 +27,7 @@ vi.mock("../lib/backend", () => ({ prepareGaussianPreview: mocks.prepareGaussianPreview, probeAndPlan: vi.fn(), releaseGaussianPreview: mocks.releaseGaussianPreview, + resumePipeline: mocks.resumePipeline, revealProject: vi.fn(), selectProjectsRoot: vi.fn(), selectVideo: vi.fn(), @@ -81,11 +83,18 @@ describe("App preview workspace", () => { useGaussianTransformStore.getState().close(); useAppStore.setState({ videoPath: null, projectsRoot: "E:\\Projects", projects: [], quality: "balanced", colmapAcceleration: null, - video: null, plan: null, engines: [], phase: "idle", progress: 0, progressMessage: "", + video: null, plan: null, estimate: null, engines: [], phase: "idle", progress: 0, progressMessage: "", latestEvent: null, events: [], result: null, error: null, }); mocks.prepareGaussianPreview.mockReset(); mocks.releaseGaussianPreview.mockReset().mockResolvedValue(undefined); + mocks.resumePipeline.mockReset().mockResolvedValue({ + projectId: project.id, projectPath: project.projectPath, finalPly: project.finalPly, + fileSize: project.fileSize, splatCount: project.splatCount, inputImages: 100, + registeredImages: 90, registeredRatio: 0.9, points3d: 10_000, + durationMs: project.durationMs, completedAt: project.completedAt, warning: null, + logsDirectory: `${project.projectPath}\\logs`, + }); mocks.getProjectOverview.mockReset().mockResolvedValue({ projectsRoot: "E:\\Projects", projects: [project] }); mocks.initializeTelemetry.mockReset().mockResolvedValue({ analyticsEnabled: true, consentDecided: true, deliveryStatus: "configured" }); mocks.setTelemetryConsent.mockReset().mockResolvedValue({ analyticsEnabled: true, consentDecided: true, deliveryStatus: "configured" }); @@ -122,6 +131,17 @@ describe("App preview workspace", () => { expect(startButton?.querySelectorAll("svg")).toHaveLength(1); }); + it("offers to continue an unfinished project from its checkpoint", async () => { + const unfinished = { ...project, status: "cancelled" as const, finalPly: null, completedAt: null }; + await act(async () => { useAppStore.setState({ projects: [unfinished] }); }); + + const resumeButton = [...container.querySelectorAll("button")].find((button) => button.textContent === "继续任务"); + await act(async () => { resumeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); + await flush(); + + expect(mocks.resumePipeline).toHaveBeenCalledWith(project.id); + }); + it("shows only the task panes until a completed project is opened", async () => { expect(container.textContent).toContain("01 创建新任务"); expect(container.textContent).toContain("02 历史任务"); diff --git a/src/app/App.tsx b/src/app/App.tsx index f63dfad3..550e9920 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -10,7 +10,7 @@ import { cancelPipeline, checkEngines, confirmAndDeleteProject, getProjectOverview, onPipelineEvent, probeAndPlan, revealProject, selectProjectsRoot, selectVideo, setProjectsRoot, startPipeline, prepareGaussianPreview, releaseGaussianPreview, - initializeTelemetry, setTelemetryConsent, + initializeTelemetry, setTelemetryConsent, resumePipeline, } from "../lib/backend"; import { startElapsedTicker } from "../lib/elapsedTimer"; import { useAppStore } from "../stores/appStore"; @@ -75,7 +75,7 @@ function engineReady(engine: EngineStatus) { return engine.canStart; } -function ProjectRow({ project, busy, previewing, previewDisabled, onPreview, onDelete }: { project: ProjectSummary; busy: boolean; previewing: boolean; previewDisabled: boolean; onPreview: (project: ProjectSummary) => void; onDelete: (project: ProjectSummary) => void }) { +function ProjectRow({ project, busy, previewing, previewDisabled, onPreview, onResume, onDelete }: { project: ProjectSummary; busy: boolean; previewing: boolean; previewDisabled: boolean; onPreview: (project: ProjectSummary) => void; onResume: (project: ProjectSummary) => void; onDelete: (project: ProjectSummary) => void }) { return
@@ -94,6 +94,7 @@ function ProjectRow({ project, busy, previewing, previewDisabled, onPreview, onD
{project.status === "completed" && } + {project.status !== "completed" && }
@@ -113,6 +114,7 @@ export function App() { const previewReleasePromises = useRef(new Map>()); const releasedPreviewProjects = useRef(new Set()); const runStartedAt = useRef(null); + const runElapsedOffset = useRef(0); const [liveElapsedMs, setLiveElapsedMs] = useState(0); const [leftPanePercent, setLeftPanePercent] = useState(() => Math.min(68, Math.max(32, readSavedNumber("ooo-splat-left-pane", 44)))); const [uiScale, setUiScale] = useState(() => Math.min(140, Math.max(80, readSavedNumber("ooo-splat-ui-scale", 100)))); @@ -129,6 +131,20 @@ export function App() { const completed = useMemo(() => store.projects.filter((project) => project.status === "completed"), [store.projects]); const unfinished = useMemo(() => store.projects.filter((project) => project.status !== "completed"), [store.projects]); const activeStageIndex = stagePosition(store.latestEvent?.stage); + const projectedTiming = useMemo(() => { + const estimate = store.estimate; + if (!estimate) return null; + let projectedTotalMs = estimate.estimatedMs; + const progressFraction = store.progress / 100; + if (isRunning && liveElapsedMs >= 10_000 && progressFraction >= 0.05) { + const observedTotal = liveElapsedMs / progressFraction; + projectedTotalMs = Math.min( + estimate.upperBoundMs * 1.25, + Math.max(estimate.lowerBoundMs * 0.8, estimate.estimatedMs * 0.55 + observedTotal * 0.45), + ); + } + return { projectedTotalMs, remainingMs: Math.max(0, projectedTotalMs - liveElapsedMs) }; + }, [isRunning, liveElapsedMs, store.estimate, store.progress]); const refreshProjects = async () => { const overview = await getProjectOverview(); @@ -158,7 +174,7 @@ export function App() { void onPipelineEvent((event) => { store.receiveEvent(event); if (["completed", "failed", "cancelled"].includes(event.stage)) { - setLiveElapsedMs(event.elapsedMs); + setLiveElapsedMs(runElapsedOffset.current + event.elapsedMs); } }).then((fn) => { unlisten = fn; }); return () => unlisten?.(); @@ -168,7 +184,9 @@ export function App() { useEffect(() => { if (!isRunning || runStartedAt.current == null) return; - return startElapsedTicker(runStartedAt.current, setLiveElapsedMs); + return startElapsedTicker(runStartedAt.current, (elapsed) => { + setLiveElapsedMs(runElapsedOffset.current + elapsed); + }); }, [isRunning]); useEffect(() => { @@ -220,7 +238,7 @@ export function App() { store.setError(null); try { const result = await probeAndPlan(path, quality); - store.setAnalysis(result.video, result.plan); + store.setAnalysis(result.video, result.plan, result.estimate); store.setPhase("idle"); } catch (error) { store.setError(messageOf(error)); @@ -250,6 +268,7 @@ export function App() { const generate = async () => { if (!store.videoPath || !store.plan || !store.projectsRoot) return; + runElapsedOffset.current = 0; runStartedAt.current = Date.now(); setLiveElapsedMs(0); store.beginRun(); @@ -271,6 +290,27 @@ export function App() { } }; + const resume = async (project: ProjectSummary) => { + runElapsedOffset.current = project.durationMs ?? 0; + runStartedAt.current = Date.now(); + setLiveElapsedMs(runElapsedOffset.current); + store.beginRun(); + try { + const result = await resumePipeline(project.id); + setLiveElapsedMs((current) => Math.max(current, result.durationMs)); + store.setResult(result); + store.setPhase("completed"); + } catch (error) { + const backendElapsed = useAppStore.getState().latestEvent?.elapsedMs ?? 0; + setLiveElapsedMs(runElapsedOffset.current + backendElapsed); + const message = messageOf(error); + store.setError(message); + store.setPhase(message.includes("取消") ? "cancelled" : "failed"); + } finally { + try { await refreshProjects(); } catch { /* the project remains on disk */ } + } + }; + const removeProject = async (project: ProjectSummary) => { try { if (await confirmAndDeleteProject(project)) { @@ -394,7 +434,7 @@ export function App() { {store.colmapAcceleration?.backend === "gpu" ? : store.colmapAcceleration && !["nvidiaSmiNotFound", "noNvidiaGpu", "macOsCpuOnly"].includes(store.colmapAcceleration.reasonCode) ? : store.colmapAcceleration ? : } {store.colmapAcceleration == null ? "正在检测 COLMAP GPU 加速…" : store.colmapAcceleration.backend === "gpu" ? "COLMAP GPU 加速已开启" : "COLMAP 使用 CPU"} - {store.colmapAcceleration == null ? "正在读取 COLMAP 加速能力" : store.colmapAcceleration.backend === "gpu" && store.colmapAcceleration.device ? `${store.colmapAcceleration.device.name} · 驱动 ${store.colmapAcceleration.device.driverVersion} · Compute Capability ${store.colmapAcceleration.device.computeCapability}` : store.colmapAcceleration.reasonCode === "macOsCpuOnly" ? store.colmapAcceleration.reason : `${store.colmapAcceleration.reason} · 最低要求:驱动 ${store.colmapAcceleration.requirements.minimumDriverVersion},Compute Capability ${store.colmapAcceleration.requirements.minimumComputeCapability}`} + {store.colmapAcceleration == null ? "正在读取 COLMAP 加速能力" : store.colmapAcceleration.backend === "gpu" && store.colmapAcceleration.device ? `${store.colmapAcceleration.device.name}${store.colmapAcceleration.device.totalMemoryMb ? ` · ${(store.colmapAcceleration.device.totalMemoryMb / 1024).toFixed(1)} GB 显存` : ""} · 驱动 ${store.colmapAcceleration.device.driverVersion} · Compute Capability ${store.colmapAcceleration.device.computeCapability}` : store.colmapAcceleration.reasonCode === "macOsCpuOnly" ? store.colmapAcceleration.reason : `${store.colmapAcceleration.reason} · 最低要求:驱动 ${store.colmapAcceleration.requirements.minimumDriverVersion},Compute Capability ${store.colmapAcceleration.requirements.minimumComputeCapability}`}
@@ -402,6 +442,7 @@ export function App() { 时长{formatVideoDuration(store.video.duration)} 分辨率{store.video.width} × {store.video.height} 预计帧数约 {store.plan.estimatedFrames.toLocaleString()} + 预计生成{store.estimate ? `约 ${formatDuration(store.estimate.estimatedMs)}` : "分析中"}{store.estimate && {formatDuration(store.estimate.lowerBoundMs)}–{formatDuration(store.estimate.upperBoundMs)}}
} {!isRunning &&